Compare commits

...

18 Commits

Author SHA1 Message Date
Jedrzej Kosinski
754d83b2bb
Merge c20a04fef0 into b08debceca 2026-07-06 17:34:00 +08:00
Daxiong (Lin)
b08debceca
chore: update embedded docs to v0.5.7 (#14783)
Some checks are pending
Detect Unreviewed Merge / detect (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Build package / Build Test (3.10) (push) Waiting to run
Build package / Build Test (3.11) (push) Waiting to run
Build package / Build Test (3.12) (push) Waiting to run
Build package / Build Test (3.13) (push) Waiting to run
Build package / Build Test (3.14) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-07-06 09:56:09 +08:00
comfyanonymous
000c6b784e
Small speedup for text model sampling. (#14773) 2026-07-05 18:39:24 -07:00
Alexander Piskun
985fb9d6ad
[Partner Nodes] fix(logs-auth): mask authorization headers in logs (#14774)
Some checks failed
Detect Unreviewed Merge / detect (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
Generate Pydantic Stubs from api.comfy.org / generate-models (push) Has been cancelled
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-07-05 13:55:29 +03:00
Alexis Rolland
7f287b705e
fix: Bug when setting transparency in color picker (#14764)
Some checks are pending
Detect Unreviewed Merge / detect (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-07-04 19:13:38 -04:00
comfyanonymous
b7ba504e06
Try to make coderabbit enforce AGENTS.md (#14759)
Some checks are pending
Detect Unreviewed Merge / detect (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-07-04 14:25:24 -04:00
Silver
6c62ca0b6b
fix: error when embedding is loaded with models using llama_template (#14744)
Some checks are pending
Detect Unreviewed Merge / detect (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-07-04 17:06:09 +08:00
Jedrzej Kosinski
c20a04fef0 Merge remote-tracking branch 'origin/master' into dynamictype-resolver 2026-06-01 20:20:00 -07:00
Jedrzej Kosinski
cc3e2abd7f 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 <amp@ampcode.com>
2026-06-01 19:33:34 -07:00
Jedrzej Kosinski
8cac12afa8 DynamicSlot: support required slots and always forceInput
- Add `optional` kwarg to DynamicSlot.Input (default True). When False,
  declaring a when=None Option is rejected because the unconnected branch
  is unreachable.
- Always publish `forceInput=True` on the slot itself. slotType may
  include widget-capable types (INT/STRING/etc.) but a DynamicSlot is
  meant to look like a connection point, never a widget.

Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501
Co-authored-by: Amp <amp@ampcode.com>
2026-06-01 19:28:15 -07:00
Jedrzej Kosinski
346ee898cb 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 <amp@ampcode.com>
2026-06-01 19:18:10 -07:00
Jedrzej Kosinski
16dd7d115c 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 <amp@ampcode.com>
2026-06-01 18:34:30 -07:00
Jedrzej Kosinski
d91c1d8d48 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 <amp@ampcode.com>
2026-06-01 17:56:32 -07:00
Jedrzej Kosinski
0e4a15b7fb 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 <amp@ampcode.com>
2026-06-01 17:08:50 -07:00
Jedrzej Kosinski
004ac8820b 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>
2026-06-01 16:54:05 -07:00
Jedrzej Kosinski
15f55f1b24 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 <amp@ampcode.com>
2026-06-01 16:43:08 -07:00
Jedrzej Kosinski
e01b335e39 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 <amp@ampcode.com>
2026-06-01 16:30:44 -07:00
Jedrzej Kosinski
19390c112a 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>
2026-06-01 16:24:48 -07:00
12 changed files with 1534 additions and 81 deletions

View File

@ -4,12 +4,12 @@ early_access: false
tone_instructions: "Only comment on issues introduced by this PR's changes. Do not flag pre-existing problems in moved, re-indented, or reformatted code."
reviews:
profile: "chill"
request_changes_workflow: false
profile: "assertive"
request_changes_workflow: true
high_level_summary: false
poem: false
review_status: false
review_details: false
review_details: true
commit_status: true
collapse_walkthrough: true
changed_files_summary: false
@ -39,6 +39,14 @@ reviews:
- path: "**"
instructions: |
IMPORTANT: Only comment on issues directly introduced by this PR's code changes.
Treat AGENTS.md as mandatory repository policy, not optional style guidance.
Flag PR changes that violate AGENTS.md even when the code is otherwise functional.
In particular, enforce architecture boundaries, dtype/device/memory rules,
interface contracts, import style, no unnecessary try/except blocks, no inline
imports, no outbound internet paths in core ComfyUI, and narrow scoped fixes.
Prefer direct findings over suggestions when a rule is violated. Only ignore
AGENTS.md when it clearly conflicts with a newer explicit maintainer instruction
in the PR.
Do NOT flag pre-existing issues in code that was merely moved, re-indented,
de-indented, or reformatted without logic changes. If code appears in the diff
only due to whitespace or structural reformatting (e.g., removing a `with:` block),
@ -123,5 +131,10 @@ chat:
knowledge_base:
opt_out: false
code_guidelines:
enabled: true
filePatterns:
- files: "AGENTS.md"
applyTo: "**"
learnings:
scope: "auto"

View File

@ -543,18 +543,24 @@ class SDTokenizer:
def _try_get_embedding(self, embedding_name:str):
'''
Takes a potential embedding name and tries to retrieve it.
Returns a Tuple consisting of the embedding and any leftover string, embedding can be None.
Returns a Tuple consisting of the embedding, the cleaned embedding name, and any leftover string, embedding can be None.
'''
split_embed = embedding_name.split()
embedding_name = split_embed[0]
leftover = ' '.join(split_embed[1:])
match = re.search(r'[<\[]', embedding_name)
if match is not None:
leftover = embedding_name[match.start():] + (" " + leftover if leftover else "")
embedding_name = embedding_name[:match.start()]
embed = load_embed(embedding_name, self.embedding_directory, self.embedding_size, self.embedding_key)
if embed is None:
stripped = embedding_name.strip(',')
if len(stripped) < len(embedding_name):
embed = load_embed(stripped, self.embedding_directory, self.embedding_size, self.embedding_key)
return (embed, "{} {}".format(embedding_name[len(stripped):], leftover))
return (embed, leftover)
return (embed, embedding_name, "{} {}".format(embedding_name[len(stripped):], leftover))
return (embed, embedding_name, leftover)
def pad_tokens(self, tokens, amount):
if self.pad_left:
@ -585,7 +591,7 @@ class SDTokenizer:
tokens = []
for weighted_segment, weight in parsed_weights:
to_tokenize = unescape_important(weighted_segment)
split = re.split(' {0}|\n{0}'.format(self.embedding_identifier), to_tokenize)
split = re.split(r'(?<=\s){}'.format(re.escape(self.embedding_identifier)), to_tokenize)
to_tokenize = [split[0]]
for i in range(1, len(split)):
to_tokenize.append("{}{}".format(self.embedding_identifier, split[i]))
@ -595,7 +601,7 @@ class SDTokenizer:
# if we find an embedding, deal with the embedding
if word.startswith(self.embedding_identifier) and self.embedding_directory is not None:
embedding_name = word[len(self.embedding_identifier):].strip('\n')
embed, leftover = self._try_get_embedding(embedding_name)
embed, embedding_name, leftover = self._try_get_embedding(embedding_name)
if embed is None:
logging.warning(f"warning, embedding:{embedding_name} does not exist, ignoring")
else:

View File

@ -937,22 +937,41 @@ class BaseGenerate:
return torch.argmax(logits, dim=-1, keepdim=True)
# Sampling mode
if repetition_penalty != 1.0:
for i in range(logits.shape[0]):
for token_id in set(token_history):
logits[i, token_id] *= repetition_penalty if logits[i, token_id] < 0 else 1/repetition_penalty
if presence_penalty is not None and presence_penalty != 0.0:
for i in range(logits.shape[0]):
for token_id in set(token_history):
logits[i, token_id] -= presence_penalty
if len(token_history) > 0 and (repetition_penalty != 1.0 or (presence_penalty is not None and presence_penalty != 0.0)):
token_ids = torch.tensor(list(set(token_history)), device=logits.device)
token_logits = logits[:, token_ids]
if repetition_penalty != 1.0:
token_logits = torch.where(token_logits < 0, token_logits * repetition_penalty, token_logits / repetition_penalty)
if presence_penalty is not None and presence_penalty != 0.0:
token_logits = token_logits - presence_penalty
logits[:, token_ids] = token_logits
if temperature != 1.0:
logits = logits / temperature
if top_k > 0:
indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
logits[indices_to_remove] = torch.finfo(logits.dtype).min
top_k = min(top_k, logits.shape[-1])
logits, top_indices = torch.topk(logits, top_k)
if min_p > 0.0:
probs_before_filter = torch.nn.functional.softmax(logits, dim=-1)
top_probs, _ = probs_before_filter.max(dim=-1, keepdim=True)
min_threshold = min_p * top_probs
indices_to_remove = probs_before_filter < min_threshold
logits[indices_to_remove] = torch.finfo(logits.dtype).min
if top_p < 1.0:
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
cumulative_probs = torch.cumsum(torch.nn.functional.softmax(sorted_logits, dim=-1), dim=-1)
sorted_indices_to_remove = cumulative_probs > top_p
sorted_indices_to_remove[..., 0] = False
indices_to_remove = torch.zeros_like(logits, dtype=torch.bool)
indices_to_remove.scatter_(1, sorted_indices, sorted_indices_to_remove)
logits[indices_to_remove] = torch.finfo(logits.dtype).min
probs = torch.nn.functional.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1, generator=generator)
return top_indices.gather(1, next_token)
if min_p > 0.0:
probs_before_filter = torch.nn.functional.softmax(logits, dim=-1)

View File

@ -1110,7 +1110,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"])
@ -1159,7 +1159,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):
@ -1197,7 +1197,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]
@ -1209,57 +1209,206 @@ 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])
@comfytype(io_type="COMFY_DYNAMICSLOT_V3")
class DynamicSlot(ComfyTypeI):
"""A slot whose revealed inputs depend on the type connected upstream.
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.
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.
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]
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
# ``_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) -> 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 (when.io_type,)
if isinstance(when, MultiType.Input):
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:
if not (isinstance(t, type) and issubclass(t, _ComfyType)):
raise ValueError(
f"DynamicSlot.Option: list entries must be ComfyType classes, got {t!r}"
)
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")
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, "
f"or a MultiType.Input; got {when!r}"
)
def as_dict(self):
return {
"when": None if self._when_types is None else list(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, 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:
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, 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
# 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 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`; "
"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),
# so get_all/get_io_type must not be overridden; slotType is published via as_dict.
def get_all(self) -> list[Input]:
return [self.slot] + self.inputs
seen_ids: set[str] = set()
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)
children.append(inp)
return [self] + children
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],
# 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):
self.slot.validate()
for input in self.inputs:
input.validate()
for opt in self.options:
for inp in opt.inputs:
inp.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 _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 state.
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:
if opt["when"] is None:
return opt
return None
resolved = (live_input_types or {}).get(finalized_id, "*")
resolved_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)
# 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 (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])
@comfytype(io_type="IMAGECOMPARE")
class ImageCompare(ComfyTypeI):
@ -1404,11 +1553,18 @@ 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 {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,
]
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():
@ -1764,7 +1920,12 @@ 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.
``live_input_types`` is an optional ``{input_id: resolved_io_type}`` map
(from ``TypeResolver``) used by future type-discriminated dynamic inputs.
"""
out_dict = {
"required": {},
"optional": {},
@ -1774,7 +1935,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 = {}
@ -1787,7 +1948,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]
@ -1795,7 +1956,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

@ -9,6 +9,7 @@ from typing import Any
import folder_paths
logger = logging.getLogger(__name__)
_SENSITIVE_HEADERS = {"authorization", "x-api-key"}
def get_log_directory():
@ -73,6 +74,10 @@ def _format_data_for_logging(data: Any) -> str:
return str(data)
def _redact_headers(headers: dict) -> dict:
return {k: ("***" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()}
def log_request_response(
operation_id: str,
request_method: str,
@ -101,7 +106,7 @@ def log_request_response(
log_content.append(f"Method: {request_method}")
log_content.append(f"URL: {request_url}")
if request_headers:
log_content.append(f"Headers:\n{_format_data_for_logging(request_headers)}")
log_content.append(f"Headers:\n{_format_data_for_logging(_redact_headers(request_headers))}")
if request_params:
log_content.append(f"Params:\n{_format_data_for_logging(request_params)}")
if request_data is not None:

View File

@ -26,6 +26,7 @@ class DynamicPrompt:
self.ephemeral_prompt = {}
self.ephemeral_parents = {}
self.ephemeral_display = {}
self._type_resolver = None # lazy; invalidated by add_ephemeral_node
def get_node(self, node_id):
if node_id in self.ephemeral_prompt:
@ -41,6 +42,17 @@ class DynamicPrompt:
self.ephemeral_prompt[node_id] = node_info
self.ephemeral_parents[node_id] = parent_id
self.ephemeral_display[node_id] = display_id
# 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()
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,369 @@
"""Server-side type resolver for prompt graphs.
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.
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
import logging
from typing import Any
from comfy_api.latest import _io as 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, else ``None``.
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 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
ANY_TYPE: str = io.AnyType.io_type
MAX_RESOLVE_DEPTH: int = 64 # belt-and-suspenders cap; real MatchType chains stay tiny
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: nodes <-> comfy_execution would cycle at import time.
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."""
self._output_cache.clear()
self._is_output_list_cache.clear()
# 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)."""
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, malformed link, or unresolved
MatchType template.
"""
# 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
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}
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
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]
# 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:
# RETURN_TYPES access above usually populates SCHEMA — 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
)
# 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,
f"node '{class_type}' output slot {slot_idx} is wildcard; defaulting to AnyType",
)
if not isinstance(resolved, str):
# 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
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:
"""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
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
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 or malformed link: MatchType slots have no declared concrete type.
if not any_input_seen:
# 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:
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]``)."""
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
_, 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
# ---- 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, malformed, 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
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:
"""Whether the value bound to ``input_id`` originates from a list output."""
node = self._get_node(node_id)
if node is None:
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.
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)
"""
_, class_def = self._get_class_def_for_node(node_id)
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:
# Top-level input id.
for inp in schema.inputs:
if inp.id == input_id:
return self._effective_io_type(inp)
# Nested (DynamicSlot / DynamicCombo `parent.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 (hidden inputs, etc.).
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 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 / 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
if isinstance(inp, io.DynamicSlot.Input):
# 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
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.
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:
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

@ -16,23 +16,30 @@ class ColorToRGBInt(io.ComfyNode):
],
outputs=[
io.Int.Output(display_name="rgb_int"),
io.Color.Output(display_name="hex")
io.Color.Output(display_name="hex"),
io.Float.Output(display_name="alpha"),
],
)
@classmethod
def execute(cls, color: str) -> io.NodeOutput:
# expect format #RRGGBB
if len(color) != 7 or color[0] != "#":
raise ValueError("Color must be in format #RRGGBB")
# expect format #RRGGBB or #RRGGBBAA
if len(color) not in (7, 9) or color[0] != "#":
raise ValueError("Color must be in format #RRGGBB or #RRGGBBAA")
try:
int(color[1:], 16)
except ValueError:
raise ValueError("Color must be in format #RRGGBB") from None
raise ValueError("Color must be in format #RRGGBB or #RRGGBBAA") from None
alpha = 1.0
if len(color) == 9:
alpha = int(color[7:9], 16) / 255.0
color = color[:7]
r, g, b = hex_to_rgb(color)
rgb_int = r * 256 * 256 + g * 256 + b
return io.NodeOutput(rgb_int, color)
return io.NodeOutput(rgb_int, color, alpha)
class ColorExtension(ComfyExtension):

View File

@ -84,8 +84,8 @@ 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.
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)
@ -153,13 +153,16 @@ 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:
valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs)
# Let dynamic schemas branch on resolved upstream types, not just literal values.
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 = {}
missing_keys = {}
for x in inputs:
@ -831,9 +834,17 @@ 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`` is built once at the top of recursion and shared so
MatchType chains are only walked once per prompt.
"""
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:
@ -865,10 +876,12 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
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()
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:
@ -918,11 +931,20 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
continue
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]]
# 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):
# 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):
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",
@ -940,7 +962,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:
@ -1068,7 +1090,11 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
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:
@ -1165,11 +1191,14 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[
errors = []
node_errors = {}
validated = {}
# 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:
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

@ -1,6 +1,6 @@
comfyui-frontend-package==1.45.20
comfyui-workflow-templates==0.11.2
comfyui-embedded-docs==0.5.6
comfyui-embedded-docs==0.5.7
torch
torchsde
torchvision

View File

@ -0,0 +1,350 @@
"""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._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 == ("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 == ("*",)
assert o.as_dict()["when"] == ["*"]
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 == ("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=[])
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
# ---------------------------------------------------------------------------
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.
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_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_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"]),
_opt(io.Mask, ["shared", "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"]
# ---------------------------------------------------------------------------
# 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_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], ["image_or_mask"]),
_opt(io.Latent, ["latent_only"]),
]
# 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 == "image_or_mask"
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"]

View File

@ -0,0 +1,482 @@
"""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_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_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(
("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"
# ---------------------------------------------------------------------------
# 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",))
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