acaa9f0168
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
Click a node name (or 'Draw this node') in the mirror-search palette to render an imitation ComfyUI node box with its real input sockets, widget defaults, and output sockets. Since disabled packs aren't loaded (no /object_info entry), a new read-only backend module (node_introspect.py) AST-parses the pack on disk — never importing or executing it — to recover INPUT_TYPES / RETURN_TYPES from a literal NODE_CLASS_MAPPINGS. New GET /nodes-stats/node-schema endpoint resolves the disabled pack dir (handling @version suffixes / case) and returns the schema off the event loop. Frontend lazily fetches + caches per node, renders sockets vs widgets with type-colored dots, and falls back to a placeholder for packs that build their node list dynamically. End-to-end against the live install: 67/68 disabled packs resolve on disk, ~92% of nodes render a real box, the rest fall back cleanly. Adds 7 parser unit tests (36 total green). Bump to 1.6.0.
132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
import os
|
|
import sys
|
|
from unittest.mock import MagicMock
|
|
|
|
import node_introspect as ni
|
|
|
|
|
|
_SAMPLE = '''
|
|
import folder_paths
|
|
|
|
|
|
class MyCoolNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"model": ("MODEL",),
|
|
"strength": ("FLOAT", {"default": 1.5, "min": 0.0}),
|
|
"mode": (["fast", "slow"], {"default": "slow"}),
|
|
"ckpt": (folder_paths.get_filename_list("checkpoints"),),
|
|
},
|
|
"optional": {
|
|
"mask": ("MASK",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "LATENT")
|
|
RETURN_NAMES = ("out_image", "out_latent")
|
|
CATEGORY = "testing/cool"
|
|
FUNCTION = "run"
|
|
|
|
def run(self, image, model, strength, mode, ckpt, mask=None):
|
|
return (image, None)
|
|
|
|
|
|
class DynamicNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
d = {"required": {}}
|
|
return d
|
|
RETURN_TYPES = ("STRING",)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {"My Cool Node": MyCoolNode}
|
|
NODE_CLASS_MAPPINGS.update({"Dynamic": DynamicNode})
|
|
NODE_DISPLAY_NAME_MAPPINGS = {"My Cool Node": "My Cool Node ✨"}
|
|
'''
|
|
|
|
|
|
def _write_pack(tmp_path, body=_SAMPLE, name="nodes.py"):
|
|
ni._INDEX_CACHE.clear()
|
|
f = tmp_path / name
|
|
f.write_text(body)
|
|
return str(tmp_path)
|
|
|
|
|
|
def test_inputs_sockets_widgets_and_defaults(tmp_path):
|
|
pack = _write_pack(tmp_path)
|
|
s = ni.get_node_schema("My Cool Node", pack)
|
|
assert s["parseable"] is True
|
|
assert s["display_name"] == "My Cool Node ✨"
|
|
assert s["category"] == "testing/cool"
|
|
by = {i["name"]: i for i in s["inputs"]}
|
|
|
|
# custom types are sockets
|
|
assert by["image"]["type"] == "IMAGE" and by["image"]["widget"] is False
|
|
assert by["model"]["type"] == "MODEL" and by["model"]["widget"] is False
|
|
# primitives + combos are widgets, with defaults
|
|
assert by["strength"]["widget"] is True and by["strength"]["default"] == 1.5
|
|
assert by["mode"]["type"] == "COMBO" and by["mode"]["options"] == ["fast", "slow"]
|
|
assert by["mode"]["default"] == "slow"
|
|
# folder_paths.get_filename_list(...) -> dynamic combo widget, options unknown
|
|
assert by["ckpt"]["type"] == "COMBO" and by["ckpt"]["widget"] is True
|
|
assert by["ckpt"]["options"] is None
|
|
# optional inputs are flagged
|
|
assert by["mask"]["required"] is False and by["image"]["required"] is True
|
|
|
|
|
|
def test_outputs_use_return_names(tmp_path):
|
|
pack = _write_pack(tmp_path)
|
|
s = ni.get_node_schema("My Cool Node", pack)
|
|
assert [(o["name"], o["type"]) for o in s["outputs"]] == [
|
|
("out_image", "IMAGE"),
|
|
("out_latent", "LATENT"),
|
|
]
|
|
|
|
|
|
def test_mapping_update_call_is_merged(tmp_path):
|
|
pack = _write_pack(tmp_path)
|
|
s = ni.get_node_schema("Dynamic", pack)
|
|
assert s["parseable"] is True
|
|
assert s["outputs"] == [{"name": "STRING", "type": "STRING"}]
|
|
assert s["inputs"] == []
|
|
|
|
|
|
def test_unknown_class_type_not_parseable(tmp_path):
|
|
pack = _write_pack(tmp_path)
|
|
s = ni.get_node_schema("Nope", pack)
|
|
assert s["parseable"] is False
|
|
assert s["reason"] == "dynamic_mapping"
|
|
|
|
|
|
def test_no_mapping_reason(tmp_path):
|
|
pack = _write_pack(tmp_path, body="class A:\n RETURN_TYPES = ('X',)\n")
|
|
s = ni.get_node_schema("A", pack)
|
|
# class_type falls back to the class name even without NODE_CLASS_MAPPINGS
|
|
assert s["parseable"] is True
|
|
s2 = ni.get_node_schema("Missing", pack)
|
|
assert s2["parseable"] is False and s2["reason"] == "no_mapping"
|
|
|
|
|
|
def test_find_disabled_pack_path_strips_version_and_case(tmp_path, monkeypatch):
|
|
cn = tmp_path / "custom_nodes"
|
|
disabled = cn / ".disabled"
|
|
disabled.mkdir(parents=True)
|
|
(disabled / "ComfyMath@nightly").mkdir()
|
|
|
|
fp = MagicMock()
|
|
fp.get_folder_paths.return_value = [str(cn)]
|
|
monkeypatch.setitem(sys.modules, "folder_paths", fp)
|
|
|
|
found = ni.find_disabled_pack_path("comfymath")
|
|
assert found == os.path.join(str(disabled), "ComfyMath@nightly")
|
|
assert ni.find_disabled_pack_path("not-there") is None
|
|
|
|
|
|
def test_find_disabled_pack_path_rejects_traversal(tmp_path):
|
|
assert ni.find_disabled_pack_path("../evil") is None
|
|
assert ni.find_disabled_pack_path("a/b") is None
|
|
assert ni.find_disabled_pack_path("") is None
|