feat(search): draw real node box from disabled-pack source
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
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:
@@ -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": "<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
|
||||
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
|
||||
|
||||
+29
@@ -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:
|
||||
|
||||
+110
-6
@@ -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;}
|
||||
</style>
|
||||
<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"
|
||||
@@ -920,9 +933,9 @@ async function openMirrorSearch() {
|
||||
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
|
||||
// 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 = [`<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.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 = `
|
||||
<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>
|
||||
${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>` : ""}
|
||||
@@ -947,12 +965,34 @@ async function openMirrorSearch() {
|
||||
</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>`;
|
||||
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 += `<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="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>
|
||||
<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)));
|
||||
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 `<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"
|
||||
// as handled by Manager like always, and Manager already surfaces missing nodes
|
||||
// on workflow load. We intentionally do NOT replicate install: a not-installed
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user