From acaa9f016887338b32cf7abb6c7c246880d77dfe Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 21 Jun 2026 15:23:58 +0200 Subject: [PATCH] feat(search): draw real node box from disabled-pack source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 16 +- __init__.py | 29 ++++ js/nodes_stats.js | 116 +++++++++++++- node_introspect.py | 287 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_node_introspect.py | 131 ++++++++++++++++ 6 files changed, 569 insertions(+), 12 deletions(-) create mode 100644 node_introspect.py create mode 100644 tests/test_node_introspect.py diff --git a/README.md b/README.md index 90d7beb..1291113 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo - **Expandable detail** — click any package to see individual node-level stats - **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time - **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install via Manager) and *Disabled*, with a temporary **Enable 7d** trial that auto-disables packages left unused -- **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, previews the owning package + its sibling nodes, and re-enables them on the spot +- **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages, draws an imitation node box (real inputs/widgets/outputs, parsed from source), and re-enables the pack on the spot - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution ## Package Classification @@ -121,9 +121,13 @@ package* and lets you re-enable the owning package right from the results. substring, finally pack-name matches) and show the `class_type` and its pack. - Hover a result (or use ↑/↓) to open a **preview panel** on the right with the owning package's title, author, description, repo link, and the full list of - sibling nodes in that pack — the active node highlighted. (A true rendered node - graphic isn't possible here: the pack is disabled, so ComfyUI hasn't loaded the - node's slot definition; the panel shows the package metadata we do have.) + sibling nodes in that pack — the active node highlighted. +- **Click a node's name** (or *Draw this node*) to render an **imitation node + box** — the real input sockets, widget defaults, and output sockets, drawn from + a static parse of the disabled pack's source. Since the pack isn't loaded, + there's no live definition to render; the backend AST-parses the pack on disk + (read-only, never executed) to recover the schema. Works for most packs (~90%); + packs that build their node list dynamically fall back to a placeholder box. - Each result offers **Enable 7d** (re-enable under a 7-day trial) and **Enable** (re-enable permanently) — in the row and in the preview panel — the same enable path as the Workflow tab. @@ -147,6 +151,7 @@ a clear message) when ComfyUI Manager is absent or there are no disabled package | `/nodes-stats/packages` | GET | Per-package aggregated stats with classification | | `/nodes-stats/usage` | GET | Raw per-node usage data | | `/nodes-stats/models` | GET | Per-type model stats with classification | +| `/nodes-stats/node-schema` | GET | Parsed input/output schema for one disabled-pack node — query `class_type`, `pack` (read-only AST parse) | | `/nodes-stats/reset` | POST | Clear all tracked data | | `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` | | `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": ""}` | @@ -268,7 +273,8 @@ timeout so the kernel keeps the listing warm across restarts, e.g. __init__.py Entry point: prompt handler, API routes mapper.py class_type → package mapping; model filename → type mapping tracker.py SQLite persistence and stats aggregation -js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs) +node_introspect.py Read-only AST parse of disabled packs → node input/output schema +js/nodes_stats.js Frontend: menu button + stats dialog (Nodes/Models/Workflow tabs) + mirror search tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot pyproject.toml Package metadata tests/ Unit tests for tracker and mapper diff --git a/__init__.py b/__init__.py index 8f9a991..858edb1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,4 @@ +import asyncio import logging import threading @@ -5,6 +6,7 @@ from aiohttp import web from server import PromptServer from .mapper import NodePackageMapper, ModelMapper +from .node_introspect import find_disabled_pack_path, get_node_schema from .tracker import UsageTracker logger = logging.getLogger(__name__) @@ -115,6 +117,33 @@ async def reset_stats(request): return web.json_response({"error": "internal error"}, status=500) +@routes.get("/nodes-stats/node-schema") +async def get_node_schema_route(request): + """Parse a disabled pack's source and return one node's input/output schema. + + Read-only and never imports/executes the pack. Used by the mirror-search + palette to draw a faithful node box for a node that isn't loaded. + """ + try: + class_type = request.query.get("class_type") + pack = request.query.get("pack") + if not class_type or not pack: + return web.json_response({"error": "class_type and pack required"}, status=400) + + def _resolve(): + path = find_disabled_pack_path(pack) + if not path: + return {"parseable": False, "reason": "source_not_found"} + return get_node_schema(class_type, path) + + # AST-parsing a whole pack can take tens of ms; keep it off the event loop. + schema = await asyncio.get_event_loop().run_in_executor(None, _resolve) + return web.json_response(schema) + except Exception: + logger.error("nodes-stats: error parsing node schema", exc_info=True) + return web.json_response({"error": "internal error"}, status=500) + + @routes.get("/nodes-stats/trials") async def get_trials(request): try: diff --git a/js/nodes_stats.js b/js/nodes_stats.js index 03d8666..27dd6c4 100644 --- a/js/nodes_stats.js +++ b/js/nodes_stats.js @@ -894,6 +894,19 @@ async function openMirrorSearch() { #nodes-stats-mirror .ns-mrow:hover{background:#262626;} #nodes-stats-mirror .ns-mrow.active{background:#1f2c1f;} #nodes-stats-mirror a{color:#6a9bd8;} + #nodes-stats-mirror .ns-mname{cursor:pointer;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + #nodes-stats-mirror .ns-mname:hover{color:#9fd0ff;text-decoration:underline;} + #nodes-stats-mirror .ns-node{border:1px solid #555;border-radius:6px;background:#2b2b2b;overflow:hidden;} + #nodes-stats-mirror .ns-node-title{background:#3a3a3a;color:#fff;font-size:12px;padding:5px 8px;border-bottom:1px solid #555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + #nodes-stats-mirror .ns-node-body{padding:6px 4px;} + #nodes-stats-mirror .ns-iorow{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:#d2d2d2;line-height:1.7;gap:8px;} + #nodes-stats-mirror .ns-io-in,#nodes-stats-mirror .ns-io-out{display:flex;align-items:center;gap:5px;min-width:0;} + #nodes-stats-mirror .ns-io-in span,#nodes-stats-mirror .ns-io-out span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + #nodes-stats-mirror .ns-io-out{justify-content:flex-end;text-align:right;} + #nodes-stats-mirror .ns-dot{width:8px;height:8px;border-radius:50%;display:inline-block;flex-shrink:0;border:1px solid rgba(0,0,0,0.4);} + #nodes-stats-mirror .ns-node-widgets{margin-top:5px;border-top:1px solid #3a3a3a;padding-top:5px;} + #nodes-stats-mirror .ns-node-widget{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:#bbb;padding:2px 4px;align-items:center;} + #nodes-stats-mirror .ns-wval{background:#1b1b1b;border:1px solid #444;border-radius:3px;padding:0 6px;color:#ddd;max-width:62%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
${escapeHtml(msg || "Hover a result to preview its package.")}
`; } - // Preview panel for the active row. We can't render a real node graphic (the - // pack is disabled, so its definition isn't loaded), so we show the pack - // metadata we do have: title/author/description + the sibling nodes in the pack. + // Preview panel for the active row: an imitation node box (built from the + // backend's static parse of the disabled pack's source — drawn on click) plus + // the pack metadata (title/author/description + sibling nodes in the pack). function renderPreview(entry) { if (!entry) { clearPreview(); return; } const m = entry.meta || {}; @@ -936,8 +949,13 @@ async function openMirrorSearch() { const meta = [`pack${escapeHtml(entry.pack)}`]; if (m.author) meta.push(`author${escapeHtml(m.author)}`); if (m.version) meta.push(`version${escapeHtml(String(m.version))}`); + const cached = _nodeSchemaCache[schemaKey(entry)]; + const nodeSection = cached !== undefined + ? nodeBoxHtml(entry, cached) + : ``; preview.innerHTML = `
${escapeHtml(entry.class_type)}
+
${nodeSection}
${meta.join("")}
${m.description ? `
${escapeHtml(m.description)}
` : ""} ${m.repo ? `
${escapeHtml(m.repo)}
` : ""} @@ -947,12 +965,34 @@ async function openMirrorSearch() {
${sibs.length} node${sibs.length !== 1 ? "s" : ""} in this pack
${sibHtml}
`; + preview.querySelector(".ns-draw-node")?.addEventListener("click", () => loadNode(entry)); preview.querySelectorAll(".ns-mirror-temp").forEach((b) => b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay))); preview.querySelectorAll(".ns-mirror-perm").forEach((b) => b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay))); } + // Fetch and cache a node's parsed schema, then redraw if it's still active. + // Definitive answers (parsed / dynamic / not-found) are cached; only transient + // network/HTTP errors are allowed to refetch on a later click. + async function loadNode(entry) { + const key = schemaKey(entry); + const cur = _nodeSchemaCache[key]; + const transient = cur && cur.parseable === false && + (cur.reason === "network" || String(cur.reason).startsWith("http")); + if (cur === undefined || transient) { + _nodeSchemaCache[key] = "loading"; + if (currentRows[activeIndex] === entry) renderPreview(entry); + try { + const r = await fetch(`/nodes-stats/node-schema?class_type=${encodeURIComponent(entry.class_type)}&pack=${encodeURIComponent(entry.pack)}`); + _nodeSchemaCache[key] = r.ok ? await r.json() : { parseable: false, reason: "http_" + r.status }; + } catch { + _nodeSchemaCache[key] = { parseable: false, reason: "network" }; + } + } + if (currentRows[activeIndex] === entry) renderPreview(entry); + } + function setActive(i) { if (!currentRows.length) { activeIndex = -1; clearPreview(); return; } activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); @@ -988,7 +1028,7 @@ async function openMirrorSearch() { for (const e of rows) { html += `
-
${escapeHtml(e.class_type)}
+
${escapeHtml(e.class_type)}
${escapeHtml(e.pack)}
@@ -1001,8 +1041,10 @@ async function openMirrorSearch() { b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay))); results.querySelectorAll(".ns-mirror-perm").forEach((b) => b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay))); - results.querySelectorAll(".ns-mrow").forEach((el, i) => - el.addEventListener("mouseenter", () => setActive(i))); + results.querySelectorAll(".ns-mrow").forEach((el, i) => { + el.addEventListener("mouseenter", () => setActive(i)); + el.querySelector(".ns-mname")?.addEventListener("click", () => { setActive(i); loadNode(currentRows[i]); }); + }); setActive(0); } @@ -1040,6 +1082,68 @@ async function mirrorEnable(pkg, temporary, overlay) { } } +// Lazily-fetched node schemas, keyed by `${pack}\n${class_type}`. A value of +// "loading" means a request is in flight; otherwise it's the parsed schema (or +// a {parseable:false} marker). Persists across palette opens for the session. +const _nodeSchemaCache = {}; +function schemaKey(entry) { return entry.pack + "\n" + entry.class_type; } + +// ComfyUI-ish accent colors per socket type, for the imitation node box. +function typeColor(t) { + const C = { + IMAGE: "#64b5f6", LATENT: "#ff79c6", MODEL: "#a78bfa", CLIP: "#fbbf24", + VAE: "#f87171", CONDITIONING: "#fb923c", MASK: "#4dd0e1", CONTROL_NET: "#80cbc4", + INT: "#9ccc65", FLOAT: "#9ccc65", STRING: "#cfd8dc", BOOLEAN: "#cfd8dc", COMBO: "#cfd8dc", + }; + return C[String(t || "").toUpperCase()] || "#9aa"; +} + +// Build the imitation node box from a parsed schema. Sockets (custom types) go +// on the sides; primitives/combos render as in-node widgets with their default. +function nodeBoxHtml(entry, s) { + if (!s || s === "loading") { + return `
drawing node…
`; + } + if (!s.parseable) { + const why = s.reason === "source_not_found" ? "pack source not found on disk" + : s.reason === "dynamic_mapping" ? "pack builds its node list dynamically" + : "schema unavailable"; + return `
${escapeHtml(entry.class_type)}
+
Can't read slots — ${escapeHtml(why)}.
Enable + restart to see the real node.
`; + } + const sockets = (s.inputs || []).filter((i) => !i.widget); + const widgets = (s.inputs || []).filter((i) => i.widget); + const outs = s.outputs || []; + const maxRows = Math.max(sockets.length, outs.length); + let io = ""; + for (let r = 0; r < maxRows; r++) { + const ip = sockets[r], op = outs[r]; + io += `
+ ${ip ? `${escapeHtml(ip.name)}` : ""} + ${op ? `${escapeHtml(op.name)}` : ""} +
`; + } + let wid = ""; + for (const w of widgets) { + let val = w.default !== null && w.default !== undefined ? w.default + : (w.options && w.options.length ? w.options[0] : ""); + if (typeof val === "boolean") val = val ? "true" : "false"; + const combo = w.type === "COMBO"; + wid += `
${escapeHtml(w.name)} + ${escapeHtml(String(val))}${combo ? " ▾" : ""}
`; + } + const counts = `${sockets.length} in · ${widgets.length} widget${widgets.length !== 1 ? "s" : ""} · ${outs.length} out${s.category ? ` · ${escapeHtml(s.category)}` : ""}`; + return `
+
${escapeHtml(s.display_name || entry.class_type)}
+
+ ${io} + ${wid ? `
${wid}
` : ""} + ${(!io && !wid) ? `
no inputs or outputs
` : ""} +
+
+
${counts}
`; +} + // Missing packages are deferred to ComfyUI Manager — the design treats "Missing" // as handled by Manager like always, and Manager already surfaces missing nodes // on workflow load. We intentionally do NOT replicate install: a not-installed diff --git a/node_introspect.py b/node_introspect.py new file mode 100644 index 0000000..010e1c1 --- /dev/null +++ b/node_introspect.py @@ -0,0 +1,287 @@ +"""Static (no-execution) introspection of disabled custom-node packages. + +The mirror-search palette previews nodes that belong to *disabled* packs. Those +packs aren't imported by ComfyUI, so their INPUT_TYPES / RETURN_TYPES are not in +/object_info. To draw a faithful node box we parse the pack's Python source with +``ast`` — we never import or execute it (importing a disabled pack could have +side effects, pull heavy deps, or fail). This yields real inputs/outputs for the +~75% of packs that declare a literal ``NODE_CLASS_MAPPINGS``; packs that build +their mappings dynamically simply report ``parseable: False`` and the frontend +falls back to a placeholder box. +""" + +import ast +import logging +import os +import warnings + +logger = logging.getLogger(__name__) + +# Input "types" that ComfyUI renders as in-node widgets rather than sockets. +_WIDGET_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "BOOL"} + +# Cache parsed pack indexes for the session, keyed by source path. Disabled +# packs don't change while ComfyUI runs, so we never invalidate. +_INDEX_CACHE = {} + + +def _const_str(node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _parse_file(path): + try: + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + src = fh.read() + # Third-party sources often contain unescaped regex strings; ast.parse + # emits SyntaxWarning for those. Suppress to keep server logs clean. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(src) + except Exception: + return None + + +def _iter_py_files(root, limit=500): + """Yield up to ``limit`` .py files under a pack dir (or the file itself).""" + if os.path.isfile(root): + if root.endswith(".py"): + yield root + return + count = 0 + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [ + d for d in dirnames + if not d.startswith(".") and d not in ("__pycache__", "node_modules", "js", "web", "dist") + ] + for fn in filenames: + if fn.endswith(".py"): + yield os.path.join(dirpath, fn) + count += 1 + if count >= limit: + return + + +def _string_tuple(node): + """A RETURN_TYPES / RETURN_NAMES value -> list of strings ("*" for non-literal).""" + if isinstance(node, (ast.Tuple, ast.List)): + out = [] + for e in node.elts: + s = _const_str(e) + out.append(s if s is not None else "*") + return out + s = _const_str(node) + return [s] if s is not None else None + + +def _extract_input_def(name, val): + """One INPUT_TYPES entry -> {name, type, widget, default, options}.""" + d = {"name": name, "type": "*", "widget": False, "default": None, "options": None} + type_node, opts_node = None, None + if isinstance(val, (ast.Tuple, ast.List)) and val.elts: + type_node = val.elts[0] + if len(val.elts) > 1: + opts_node = val.elts[1] + else: + type_node = val + + s = _const_str(type_node) + if s is not None: + d["type"] = s + if s.upper() in _WIDGET_TYPES: + d["widget"] = True + elif isinstance(type_node, (ast.List, ast.Tuple)): + # Inline combo box: ["a", "b", ...] + opts = [_const_str(e) for e in type_node.elts] + opts = [o for o in opts if o is not None] + d["type"] = "COMBO" + d["widget"] = True + d["options"] = opts or None + elif isinstance(type_node, ast.Call): + # Dynamic list, e.g. folder_paths.get_filename_list(...) -> dropdown widget. + d["type"] = "COMBO" + d["widget"] = True + # else: a Name/Attribute (custom or wildcard socket type) -> keep "*", socket. + + if isinstance(opts_node, ast.Dict): + for ok, ov in zip(opts_node.keys, opts_node.values): + if _const_str(ok) == "default": + try: + d["default"] = ast.literal_eval(ov) + except Exception: + d["default"] = _const_str(ov) + return d + + +def _extract_input_types(fn): + """The INPUT_TYPES classmethod -> {"required": [...], "optional": [...]} or None.""" + ret = None + for n in ast.walk(fn): + if isinstance(n, ast.Return) and isinstance(n.value, ast.Dict): + ret = n.value + break + if ret is None: + return None + result = {"required": [], "optional": []} + for cat_key, cat_val in zip(ret.keys, ret.values): + cat = _const_str(cat_key) + if cat not in ("required", "optional") or not isinstance(cat_val, ast.Dict): + continue + for nk, nv in zip(cat_val.keys, cat_val.values): + name = _const_str(nk) + if name is not None: + result[cat].append(_extract_input_def(name, nv)) + return result + + +def _extract_class(cls): + info = {"input_types": None, "return_types": None, "return_names": None, + "category": None, "output_node": False} + for b in cls.body: + if isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef)) and b.name == "INPUT_TYPES": + info["input_types"] = _extract_input_types(b) + elif isinstance(b, ast.Assign): + for t in b.targets: + tn = getattr(t, "id", None) + if tn == "RETURN_TYPES": + info["return_types"] = _string_tuple(b.value) + elif tn == "RETURN_NAMES": + info["return_names"] = _string_tuple(b.value) + elif tn == "CATEGORY": + info["category"] = _const_str(b.value) + elif tn == "OUTPUT_NODE" and isinstance(b.value, ast.Constant): + info["output_node"] = bool(b.value.value) + return info + + +def _merge_mapping(out, dictnode): + if not isinstance(dictnode, ast.Dict): + return + for k, v in zip(dictnode.keys, dictnode.values): + key = _const_str(k) + if key is None: + continue + if isinstance(v, ast.Name): + out[key] = v.id + elif isinstance(v, ast.Call) and isinstance(v.func, ast.Name): + out[key] = v.func.id + + +def _merge_display(out, dictnode): + if not isinstance(dictnode, ast.Dict): + return + for k, v in zip(dictnode.keys, dictnode.values): + key, val = _const_str(k), _const_str(v) + if key is not None and val is not None: + out[key] = val + + +def build_pack_index(pack_path): + """Parse a pack -> (classes, mappings, display). + + classes: { ClassName: {input_types, return_types, return_names, category, output_node} } + mappings: { node_key: ClassName } (from literal NODE_CLASS_MAPPINGS / .update) + display: { node_key: "Pretty Name" } + """ + cached = _INDEX_CACHE.get(pack_path) + if cached is not None: + return cached + + classes, mappings, display = {}, {}, {} + for f in _iter_py_files(pack_path): + tree = _parse_file(f) + if tree is None: + continue + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + classes.setdefault(node.name, _extract_class(node)) + elif isinstance(node, ast.Assign): + for t in node.targets: + name = getattr(t, "id", None) + if name == "NODE_CLASS_MAPPINGS": + _merge_mapping(mappings, node.value) + elif name == "NODE_DISPLAY_NAME_MAPPINGS": + _merge_display(display, node.value) + elif isinstance(node, ast.Call): + fn = node.func + if (isinstance(fn, ast.Attribute) and fn.attr == "update" + and isinstance(fn.value, ast.Name) and node.args + and isinstance(node.args[0], ast.Dict)): + if fn.value.id == "NODE_CLASS_MAPPINGS": + _merge_mapping(mappings, node.args[0]) + elif fn.value.id == "NODE_DISPLAY_NAME_MAPPINGS": + _merge_display(display, node.args[0]) + + result = (classes, mappings, display) + _INDEX_CACHE[pack_path] = result + return result + + +def get_node_schema(class_type, pack_path): + """Return a render-ready schema for one node, or {parseable: False, reason}.""" + classes, mappings, display = build_pack_index(pack_path) + + cls_name = mappings.get(class_type) + if cls_name is None and class_type in classes: + cls_name = class_type # fall back: class_type IS the class name + if cls_name is None or cls_name not in classes: + return {"parseable": False, "reason": "dynamic_mapping" if mappings else "no_mapping"} + + info = classes[cls_name] + inputs = [] + it = info["input_types"] + if it: + for cat in ("required", "optional"): + for d in it[cat]: + inputs.append({**d, "required": cat == "required"}) + + rt = info["return_types"] or [] + rn = info["return_names"] or [] + outputs = [] + for i, t in enumerate(rt): + nm = rn[i] if i < len(rn) and rn[i] else t + outputs.append({"name": nm or t, "type": t}) + + return { + "parseable": True, + "class_type": class_type, + "display_name": display.get(class_type) or class_type, + "category": info["category"], + "output_node": info["output_node"], + "inputs": inputs, + "outputs": outputs, + } + + +def find_disabled_pack_path(pack_name): + """Locate a disabled pack's source under any custom_nodes/.disabled/ dir. + + Matches case-insensitively and ignores any ``@version`` suffix that ComfyUI + Manager appends on disk (e.g. ``ComfyMath@nightly`` for pack ``comfymath``). + Returns an absolute path (dir or .py file) or None. Rejects path-y input. + """ + if not pack_name or any(c in pack_name for c in ("/", "\\")) or ".." in pack_name: + return None + try: + import folder_paths + roots = folder_paths.get_folder_paths("custom_nodes") + except Exception: + roots = [] + + target = pack_name.lower() + for root in roots: + ddir = os.path.join(root, ".disabled") + if not os.path.isdir(ddir): + continue + try: + entries = os.listdir(ddir) + except Exception: + continue + for e in entries: + base = e.split("@", 1)[0] + stem = base[:-3] if base.endswith(".py") else base + if target in (stem.lower(), base.lower()): + return os.path.join(ddir, e) + return None diff --git a/pyproject.toml b/pyproject.toml index 89f4f5a..7fca07f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-nodes-stats" description = "Track usage statistics for all ComfyUI nodes and packages" -version = "1.5.0" +version = "1.6.0" license = "MIT" [project.urls] diff --git a/tests/test_node_introspect.py b/tests/test_node_introspect.py new file mode 100644 index 0000000..89a013e --- /dev/null +++ b/tests/test_node_introspect.py @@ -0,0 +1,131 @@ +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