feat(search): draw real node box from disabled-pack source
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.
This commit is contained in:
2026-06-21 15:23:58 +02:00
parent 77c159a918
commit acaa9f0168
6 changed files with 569 additions and 12 deletions
+11 -5
View File
@@ -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 - **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 - **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 - **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 - **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
## Package Classification ## 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. 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 - 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 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 sibling nodes in that pack — the active node highlighted.
graphic isn't possible here: the pack is disabled, so ComfyUI hasn't loaded the - **Click a node's name** (or *Draw this node*) to render an **imitation node
node's slot definition; the panel shows the package metadata we do have.) 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** - 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 (re-enable permanently) — in the row and in the preview panel — the same enable
path as the Workflow tab. 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/packages` | GET | Per-package aggregated stats with classification |
| `/nodes-stats/usage` | GET | Raw per-node usage data | | `/nodes-stats/usage` | GET | Raw per-node usage data |
| `/nodes-stats/models` | GET | Per-type model stats with classification | | `/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/reset` | POST | Clear all tracked data |
| `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` | | `/nodes-stats/trials` | GET | Active temporary-enable trials with `days_remaining`/`expired` |
| `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": "<dir-name>"}` | | `/nodes-stats/trials/start` | POST | Begin/restart a trial — body `{"package": "<dir-name>"}` |
@@ -268,7 +273,8 @@ timeout so the kernel keeps the listing warm across restarts, e.g.
__init__.py Entry point: prompt handler, API routes __init__.py Entry point: prompt handler, API routes
mapper.py class_type → package mapping; model filename → type mapping mapper.py class_type → package mapping; model filename → type mapping
tracker.py SQLite persistence and stats aggregation 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 tools/diagnose_model_scan.py Standalone: diagnose slow model-folder scans at boot
pyproject.toml Package metadata pyproject.toml Package metadata
tests/ Unit tests for tracker and mapper tests/ Unit tests for tracker and mapper
+29
View File
@@ -1,3 +1,4 @@
import asyncio
import logging import logging
import threading import threading
@@ -5,6 +6,7 @@ from aiohttp import web
from server import PromptServer from server import PromptServer
from .mapper import NodePackageMapper, ModelMapper from .mapper import NodePackageMapper, ModelMapper
from .node_introspect import find_disabled_pack_path, get_node_schema
from .tracker import UsageTracker from .tracker import UsageTracker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -115,6 +117,33 @@ async def reset_stats(request):
return web.json_response({"error": "internal error"}, status=500) 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") @routes.get("/nodes-stats/trials")
async def get_trials(request): async def get_trials(request):
try: try:
+110 -6
View File
@@ -894,6 +894,19 @@ async function openMirrorSearch() {
#nodes-stats-mirror .ns-mrow:hover{background:#262626;} #nodes-stats-mirror .ns-mrow:hover{background:#262626;}
#nodes-stats-mirror .ns-mrow.active{background:#1f2c1f;} #nodes-stats-mirror .ns-mrow.active{background:#1f2c1f;}
#nodes-stats-mirror a{color:#6a9bd8;} #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;}
</style> </style>
<div style="padding:12px;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;"> <div style="padding:12px;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;">
<input id="ns-mirror-input" placeholder="search disabled-pack nodes…" autocomplete="off" <input id="ns-mirror-input" placeholder="search disabled-pack nodes…" autocomplete="off"
@@ -920,9 +933,9 @@ async function openMirrorSearch() {
preview.innerHTML = `<div style="color:#666;">${escapeHtml(msg || "Hover a result to preview its package.")}</div>`; preview.innerHTML = `<div style="color:#666;">${escapeHtml(msg || "Hover a result to preview its package.")}</div>`;
} }
// Preview panel for the active row. We can't render a real node graphic (the // Preview panel for the active row: an imitation node box (built from the
// pack is disabled, so its definition isn't loaded), so we show the pack // backend's static parse of the disabled pack's source — drawn on click) plus
// metadata we do have: title/author/description + the sibling nodes in the pack. // the pack metadata (title/author/description + sibling nodes in the pack).
function renderPreview(entry) { function renderPreview(entry) {
if (!entry) { clearPreview(); return; } if (!entry) { clearPreview(); return; }
const m = entry.meta || {}; const m = entry.meta || {};
@@ -936,8 +949,13 @@ async function openMirrorSearch() {
const meta = [`<span style="color:#777;">pack</span><span style="color:#ccc;word-break:break-all;">${escapeHtml(entry.pack)}</span>`]; const meta = [`<span style="color:#777;">pack</span><span style="color:#ccc;word-break:break-all;">${escapeHtml(entry.pack)}</span>`];
if (m.author) meta.push(`<span style="color:#777;">author</span><span style="color:#ccc;">${escapeHtml(m.author)}</span>`); if (m.author) meta.push(`<span style="color:#777;">author</span><span style="color:#ccc;">${escapeHtml(m.author)}</span>`);
if (m.version) meta.push(`<span style="color:#777;">version</span><span style="color:#ccc;">${escapeHtml(String(m.version))}</span>`); if (m.version) meta.push(`<span style="color:#777;">version</span><span style="color:#ccc;">${escapeHtml(String(m.version))}</span>`);
const cached = _nodeSchemaCache[schemaKey(entry)];
const nodeSection = cached !== undefined
? nodeBoxHtml(entry, cached)
: `<button class="ns-btn ns-draw-node" style="width:100%;">▭ Draw this node</button>`;
preview.innerHTML = ` preview.innerHTML = `
<div style="color:#fff;font-size:14px;word-break:break-word;margin-bottom:10px;">${escapeHtml(entry.class_type)}</div> <div style="color:#fff;font-size:14px;word-break:break-word;margin-bottom:10px;">${escapeHtml(entry.class_type)}</div>
<div id="ns-nodebox" style="margin-bottom:12px;">${nodeSection}</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;margin-bottom:10px;">${meta.join("")}</div> <div style="display:grid;grid-template-columns:auto 1fr;gap:3px 8px;font-size:11px;margin-bottom:10px;">${meta.join("")}</div>
${m.description ? `<div style="color:#aaa;font-size:11px;font-style:italic;border-left:2px solid #444;padding-left:8px;margin-bottom:10px;">${escapeHtml(m.description)}</div>` : ""} ${m.description ? `<div style="color:#aaa;font-size:11px;font-style:italic;border-left:2px solid #444;padding-left:8px;margin-bottom:10px;">${escapeHtml(m.description)}</div>` : ""}
${m.repo ? `<div style="margin-bottom:12px;"><a href="${escapeAttr(m.repo)}" target="_blank" rel="noopener" style="font-size:11px;word-break:break-all;">${escapeHtml(m.repo)}</a></div>` : ""} ${m.repo ? `<div style="margin-bottom:12px;"><a href="${escapeAttr(m.repo)}" target="_blank" rel="noopener" style="font-size:11px;word-break:break-all;">${escapeHtml(m.repo)}</a></div>` : ""}
@@ -947,12 +965,34 @@ async function openMirrorSearch() {
</div> </div>
<div style="color:#777;font-size:11px;margin-bottom:4px;">${sibs.length} node${sibs.length !== 1 ? "s" : ""} in this pack</div> <div style="color:#777;font-size:11px;margin-bottom:4px;">${sibs.length} node${sibs.length !== 1 ? "s" : ""} in this pack</div>
<div style="font-size:11px;">${sibHtml}</div>`; <div style="font-size:11px;">${sibHtml}</div>`;
preview.querySelector(".ns-draw-node")?.addEventListener("click", () => loadNode(entry));
preview.querySelectorAll(".ns-mirror-temp").forEach((b) => preview.querySelectorAll(".ns-mirror-temp").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay))); b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
preview.querySelectorAll(".ns-mirror-perm").forEach((b) => preview.querySelectorAll(".ns-mirror-perm").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay))); 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) { function setActive(i) {
if (!currentRows.length) { activeIndex = -1; clearPreview(); return; } if (!currentRows.length) { activeIndex = -1; clearPreview(); return; }
activeIndex = Math.max(0, Math.min(i, currentRows.length - 1)); activeIndex = Math.max(0, Math.min(i, currentRows.length - 1));
@@ -988,7 +1028,7 @@ async function openMirrorSearch() {
for (const e of rows) { for (const e of rows) {
html += `<div class="ns-mrow" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #222;"> html += `<div class="ns-mrow" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #222;">
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<div style="color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(e.class_type)}</div> <div class="ns-mname" title="Draw this node">${escapeHtml(e.class_type)}</div>
<div style="color:#888;font-size:11px;">${escapeHtml(e.pack)}</div> <div style="color:#888;font-size:11px;">${escapeHtml(e.pack)}</div>
</div> </div>
<button class="ns-btn ns-mirror-temp" data-pkg="${escapeAttr(e.pack)}">Enable 7d</button> <button class="ns-btn ns-mirror-temp" data-pkg="${escapeAttr(e.pack)}">Enable 7d</button>
@@ -1001,8 +1041,10 @@ async function openMirrorSearch() {
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay))); b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
results.querySelectorAll(".ns-mirror-perm").forEach((b) => results.querySelectorAll(".ns-mirror-perm").forEach((b) =>
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay))); b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay)));
results.querySelectorAll(".ns-mrow").forEach((el, i) => results.querySelectorAll(".ns-mrow").forEach((el, i) => {
el.addEventListener("mouseenter", () => setActive(i))); el.addEventListener("mouseenter", () => setActive(i));
el.querySelector(".ns-mname")?.addEventListener("click", () => { setActive(i); loadNode(currentRows[i]); });
});
setActive(0); 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 `<div style="color:#888;font-size:11px;padding:4px 2px;">drawing node…</div>`;
}
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 `<div class="ns-node"><div class="ns-node-title">${escapeHtml(entry.class_type)}</div>
<div style="padding:8px;color:#888;font-size:11px;">Can't read slots — ${escapeHtml(why)}.<br>Enable + restart to see the real node.</div></div>`;
}
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 += `<div class="ns-iorow">
<span class="ns-io-in">${ip ? `<span class="ns-dot" style="background:${typeColor(ip.type)};"></span><span title="${escapeAttr(ip.type)}">${escapeHtml(ip.name)}</span>` : ""}</span>
<span class="ns-io-out">${op ? `<span title="${escapeAttr(op.type)}">${escapeHtml(op.name)}</span><span class="ns-dot" style="background:${typeColor(op.type)};"></span>` : ""}</span>
</div>`;
}
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 += `<div class="ns-node-widget"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(w.name)}</span>
<span class="ns-wval">${escapeHtml(String(val))}${combo ? " ▾" : ""}</span></div>`;
}
const counts = `${sockets.length} in · ${widgets.length} widget${widgets.length !== 1 ? "s" : ""} · ${outs.length} out${s.category ? ` · ${escapeHtml(s.category)}` : ""}`;
return `<div class="ns-node">
<div class="ns-node-title">${escapeHtml(s.display_name || entry.class_type)}</div>
<div class="ns-node-body">
${io}
${wid ? `<div class="ns-node-widgets">${wid}</div>` : ""}
${(!io && !wid) ? `<div style="padding:6px;color:#777;font-size:11px;">no inputs or outputs</div>` : ""}
</div>
</div>
<div style="color:#666;font-size:10px;margin-top:4px;">${counts}</div>`;
}
// Missing packages are deferred to ComfyUI Manager — the design treats "Missing" // Missing packages are deferred to ComfyUI Manager — the design treats "Missing"
// as handled by Manager like always, and Manager already surfaces missing nodes // as handled by Manager like always, and Manager already surfaces missing nodes
// on workflow load. We intentionally do NOT replicate install: a not-installed // on workflow load. We intentionally do NOT replicate install: a not-installed
+287
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-nodes-stats" name = "comfyui-nodes-stats"
description = "Track usage statistics for all ComfyUI nodes and packages" description = "Track usage statistics for all ComfyUI nodes and packages"
version = "1.5.0" version = "1.6.0"
license = "MIT" license = "MIT"
[project.urls] [project.urls]
+131
View File
@@ -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