Files
ausboss-ComfyUI-AusBoss/scripts/validate_nodes.py
T
ausboss 85b70389ff Add Align Image, Image Size, and LM Studio Chat
Align Image snaps width and height to a clean multiple - 16, 32,
whatever the model wants - because Qwen image models, VAEs and plenty of
samplers only behave on cleanly divisible sizes. Each mode implies its
own rounding, so there is no separate direction widget: resize rescales
to the nearest multiple, crop center-crops down, pad replicate-pads up,
and a side smaller than one multiple always grows to exactly one rather
than erroring. The new size comes out as INTs for wiring into latents.

Image Size reads width, height, longest edge and shortest edge off the
wire so resizes and latents key off the actual image instead of retyped
numbers that drift.

LM Studio Chat posts a prompt - and optionally one image - to a local
LM Studio server, or anything else speaking the OpenAI-compatible chat
API. The interface is the middle ground the installed ecosystem packs
miss on either side: endpoint string, model string where EMPTY means
whatever the server has loaded (a dropdown fetched at ComfyUI startup
goes stale whenever LM Studio was not running yet), optional system
prompt, prompt required unless an image is connected, temperature,
max_tokens, seed as the re-roll knob, a timeout, and an image downscale
cap. Reasoning-model think blocks land on their own output so the text
stays clean for conditioning. Stdlib urllib only - no new dependencies -
and the POST runs off the executor thread. Every error names what to
fix, with the server's own words when it had any.

Verified live against a running LM Studio: a JIT-loaded qwen3.5-9b
answered a text round trip in 7.6 s through the real node; the
no-model-loaded 400 and a dead port both surfaced their actionable
messages. The vision payload shape is unit-tested; the two local VL
quants refused images server-side (missing projector / failed load), so
the live vision check stays on the deployment checklist.

48 offline tests cover the alignment math, both size nodes, the URL and
payload builders, think-splitting, image encoding, and every mocked
network error path.
2026-08-15 20:51:51 -05:00

180 lines
7.3 KiB
Python

#!/usr/bin/env python
"""Offline sanity checks for the node pack — no ComfyUI required.
Run from anywhere: python scripts/validate_nodes.py
Checks:
1. Every .py file in the pack compiles.
2. Every nodes/node_*.py keeps the registry contract from
scripts/registry_contract.py: both mappings assigned exactly once, at
module level, to a non-empty dictionary literal with string-literal
keys, never mentioned again afterwards, and carrying the same keys as
each other. Registry scanners parse the source rather than importing
it, so anything they cannot read statically makes the node invisible.
3. NODE_MODULES in __init__.py is parsed (not grepped) and matched
against the files on disk both ways - a listed module with no file and
a node file nobody imports are both errors - and no mapping key is
claimed by two modules.
5. No file outside nodes/ declares mapping keys. Registry scanners read
the whole checkout, so a fixture or sample that names AUSBOSS_NODES_*
in a mapping literal is advertised as an installable node.
4. Permanent public node IDs are still registered - measured against the
mapping keys parsed in check 2, not against the text of the file - and
each public transform node declares exactly IMAGE then MASK outputs.
Exit code 0 = all good, 1 = problems printed below.
"""
import ast
import pathlib
import py_compile
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from registry_contract import (
class_mapping_keys,
duplicate_key_problems,
mapping_problems,
module_list,
)
errors = []
warnings = []
PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_AlignImage",
"AUSBOSS_NODES_ColorMatch",
"AUSBOSS_NODES_Compare",
"AUSBOSS_NODES_CropForInpaint",
"AUSBOSS_NODES_DropShadow",
"AUSBOSS_NODES_FrameChooser",
"AUSBOSS_NODES_FrameInterpolate",
"AUSBOSS_NODES_ImageCropRotatePad",
"AUSBOSS_NODES_ImageSize",
"AUSBOSS_NODES_LaMaInpaint",
"AUSBOSS_NODES_LmStudioChat",
"AUSBOSS_NODES_LoadVideo",
"AUSBOSS_NODES_LoraLoader",
"AUSBOSS_NODES_PadImage",
"AUSBOSS_NODES_RefineMask",
"AUSBOSS_NODES_SaveVideo",
"AUSBOSS_NODES_SelectFrame",
"AUSBOSS_NODES_SelectFrameRange",
"AUSBOSS_NODES_VideoBundle",
"AUSBOSS_NODES_VideoBundleEdit",
"AUSBOSS_NODES_StitchInpaint",
"AUSBOSS_NODES_VideoCropRotatePad",
"AUSBOSS_NODES_VideoUnbundle",
}
# --- 1. everything compiles --------------------------------------------------
for path in sorted(ROOT.rglob("*.py")):
if "__pycache__" in path.parts:
continue
try:
py_compile.compile(str(path), doraise=True)
except py_compile.PyCompileError as exc:
errors.append(f"syntax error: {path.relative_to(ROOT)}\n {exc.msg}")
# --- 2. the registry contract each node module must keep --------------------
node_files = sorted((ROOT / "nodes").glob("node_*.py"))
keys_by_module = {}
for path in node_files:
source = path.read_text(encoding="utf-8")
errors.extend(mapping_problems(source, path.name))
keys_by_module[path.name] = class_mapping_keys(source)
# Two modules claiming one key is a silent drop: the last import wins.
errors.extend(duplicate_key_problems(keys_by_module))
# --- 3. NODE_MODULES list matches the files on disk --------------------------
# Parsed out of the list literal, not matched anywhere in the text: a name
# left behind in a comment used to satisfy this, and because an unlisted
# module was only a warning, forgetting to register a node exited 0 with
# nothing printed at all.
init_text = (ROOT / "__init__.py").read_text(encoding="utf-8")
listed = module_list(init_text)
on_disk = {path.stem for path in node_files}
if listed is None:
errors.append(
"__init__.py: NODE_MODULES must be a module-level list literal of "
"module names; this checker reads it without importing the package"
)
else:
for missing in sorted(listed - on_disk):
errors.append(f"__init__.py lists {missing} but nodes/{missing}.py does not exist")
for orphan in sorted(on_disk - listed):
# Not a warning: an unlisted module is never imported, so the node
# simply does not exist in ComfyUI.
errors.append(
f"nodes/{orphan}.py is not listed in NODE_MODULES, so it is never imported"
)
# --- 4. permanent transform contracts ---------------------------------------
# The keys parsed out of NODE_CLASS_MAPPINGS in check 2, not every
# AUSBOSS_NODES_* string in the file: a docstring, a comment or a search
# alias mentioning an id is not a registration, and matching those would let
# a node lose its mapping entry while this check kept passing.
mapping_keys = set().union(*keys_by_module.values()) if keys_by_module else set()
for path in node_files:
text = path.read_text(encoding="utf-8")
if path.stem in {"node_image_crop_rotate_pad", "node_video_crop_rotate_pad"}:
tree = ast.parse(text)
return_types = None
return_names = None
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id in {"RETURN_TYPES", "RETURN_NAMES"}:
try:
value = ast.literal_eval(node.value)
except Exception:
value = None
if target.id == "RETURN_TYPES":
return_types = value
else:
return_names = value
if return_types != ("IMAGE", "MASK"):
errors.append(f"{path.name}: RETURN_TYPES must be exactly ('IMAGE', 'MASK')")
if return_names != ("image", "mask"):
errors.append(f"{path.name}: RETURN_NAMES must be exactly ('image', 'mask')")
for missing_id in sorted(PUBLIC_NODE_IDS - mapping_keys):
errors.append(f"missing permanent mapping key: {missing_id}")
# --- 5. nothing outside nodes/ may declare mapping keys ----------------------
# A scanner reads the whole checkout, not just the modules __init__ imports,
# so any .py anywhere that assigns NODE_CLASS_MAPPINGS advertises those keys
# as installable nodes. Test fixtures did exactly that: they published ids no
# import ever registers, and Manager offered the pack for workflows using
# them. Fixtures live as .py.txt for this reason - real source, not a module.
for path in sorted(ROOT.rglob("*.py")):
parts = path.relative_to(ROOT).parts
# Scanners skip dot-directories and caches; so does this, or local
# scratch like .claude/worktrees would answer for the shipped tree.
if any(part.startswith(".") or part == "__pycache__" for part in parts):
continue
if path.parent == ROOT / "nodes":
continue
stray = class_mapping_keys(path.read_text(encoding="utf-8"))
for key in sorted(stray):
errors.append(
f"{path.relative_to(ROOT)}: declares mapping key {key} outside "
"nodes/, where registry scanners will advertise it as a node"
)
# --- report ------------------------------------------------------------------
for warning in warnings:
print(f"WARN {warning}")
for error in errors:
print(f"ERROR {error}")
if errors:
print(f"\n{len(errors)} problem(s) found.")
sys.exit(1)
print(f"OK - {len(node_files)} node file(s) validated.")