316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Released node inputs and outputs only ever grow.
|
|
|
|
Saved workflows keep widget values by position and links by slot index, and
|
|
an API prompt must supply every required input. So an input may only be
|
|
appended as optional and an output only appended; nothing is renamed,
|
|
reordered, removed or made required. tests/fixtures/node_api.json pins the
|
|
current API. After a deliberate, compatible change (or a new node), refresh
|
|
it with ComfyUI's Python:
|
|
|
|
AUSBOSS_COMFY_ROOT=<ComfyUI> python tests/test_node_api.py --update
|
|
|
|
Without ComfyUI (in CI) the same comparison runs on an offline load of the
|
|
pack: a fresh standard-library Python with torch, NumPy, Pillow, PyAV and
|
|
SciPy replaced by stand-ins that accept anything. Input and output names
|
|
come from the node code itself, so the stand-ins cannot change them; with
|
|
ComfyUI, a test checks that the offline load describes the pack exactly as
|
|
the real one does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import importlib.abc
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import unittest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SNAPSHOT = ROOT / "tests" / "fixtures" / "node_api.json"
|
|
COMFY_ROOT = os.environ.get("AUSBOSS_COMFY_ROOT")
|
|
|
|
# The libraries node modules import when they load. Everything else the
|
|
# pack touches (ComfyUI's own modules, optional extras) is imported inside
|
|
# functions or behind an ImportError fallback.
|
|
OFFLINE_STAND_INS = ("torch", "numpy", "PIL", "av", "scipy")
|
|
|
|
|
|
def stub_server():
|
|
# Route registration needs a running PromptServer; reading the API does not.
|
|
class Routes:
|
|
def __getattr__(self, _name):
|
|
return lambda *_args, **_kwargs: (lambda handler: handler)
|
|
|
|
class Instance:
|
|
routes = Routes()
|
|
|
|
def __getattr__(self, _name):
|
|
return lambda *_args, **_kwargs: None
|
|
|
|
module = types.ModuleType("server")
|
|
module.PromptServer = type("PromptServer", (), {"instance": Instance()})
|
|
sys.modules["server"] = module
|
|
|
|
|
|
def load_pack():
|
|
"""Import the pack the way ComfyUI does, fail-soft loader included."""
|
|
sys.path.insert(0, COMFY_ROOT)
|
|
sys.argv = sys.argv[:1] # ComfyUI's argument parser reads sys.argv
|
|
# Reading the API needs no GPU. CPU mode keeps the test off the card and
|
|
# lets it run with the GPU hidden (CUDA_VISIBLE_DEVICES="").
|
|
import comfy.cli_args
|
|
|
|
comfy.cli_args.args.cpu = True
|
|
stub_server()
|
|
spec = importlib.util.spec_from_file_location(
|
|
"ausboss_node_api", ROOT / "__init__.py", submodule_search_locations=[str(ROOT)]
|
|
)
|
|
pack = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = pack
|
|
spec.loader.exec_module(pack)
|
|
return pack
|
|
|
|
|
|
class StandIn:
|
|
"""Any attribute, call, item, base class or number a module-level line asks for."""
|
|
|
|
def __init__(self, *_args, **_kwargs):
|
|
pass
|
|
|
|
def __getattr__(self, name):
|
|
if name.startswith("__") and name.endswith("__"):
|
|
raise AttributeError(name)
|
|
return StandIn()
|
|
|
|
def __call__(self, *args, **kwargs):
|
|
# Used as a decorator (@torch.no_grad()), it hands the function back.
|
|
if len(args) == 1 and not kwargs and callable(args[0]) and not isinstance(args[0], StandIn):
|
|
return args[0]
|
|
return StandIn()
|
|
|
|
def __mro_entries__(self, _bases):
|
|
return (StandIn,)
|
|
|
|
def __getitem__(self, _key):
|
|
return StandIn()
|
|
|
|
def __iter__(self):
|
|
return iter(())
|
|
|
|
def __bool__(self):
|
|
return False
|
|
|
|
def __len__(self):
|
|
return 0
|
|
|
|
def __index__(self):
|
|
return 0
|
|
|
|
__int__ = __index__
|
|
|
|
def __float__(self):
|
|
return 0.0
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_exc):
|
|
return False
|
|
|
|
def __or__(self, _other):
|
|
return StandIn()
|
|
|
|
__ror__ = __add__ = __radd__ = __sub__ = __rsub__ = __mul__ = __rmul__ = __or__
|
|
__truediv__ = __rtruediv__ = __pow__ = __neg__ = __or__
|
|
|
|
|
|
class StandInModule(types.ModuleType):
|
|
def __getattr__(self, name):
|
|
if name.startswith("__") and name.endswith("__"):
|
|
raise AttributeError(name)
|
|
return StandIn()
|
|
|
|
|
|
class StandInFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
def find_spec(self, name, _path=None, _target=None):
|
|
if name.split(".")[0] in OFFLINE_STAND_INS:
|
|
return importlib.machinery.ModuleSpec(name, self, is_package=True)
|
|
return None
|
|
|
|
def create_module(self, spec):
|
|
module = StandInModule(spec.name)
|
|
module.__path__ = []
|
|
return module
|
|
|
|
def exec_module(self, _module):
|
|
pass
|
|
|
|
|
|
def load_pack_offline():
|
|
"""Import the pack with no ComfyUI and stand-ins for its heavy libraries."""
|
|
sys.meta_path.insert(0, StandInFinder())
|
|
stub_server()
|
|
spec = importlib.util.spec_from_file_location(
|
|
"ausboss_node_api_offline", ROOT / "__init__.py", submodule_search_locations=[str(ROOT)]
|
|
)
|
|
pack = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = pack
|
|
spec.loader.exec_module(pack)
|
|
return pack
|
|
|
|
|
|
def describe_offline() -> tuple[dict, list[str]]:
|
|
"""describe() of an offline load, run in a fresh standard-library Python."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "api.json"
|
|
# -S leaves out site-packages, so every machine loads what CI loads.
|
|
result = subprocess.run(
|
|
[sys.executable, "-S", str(Path(__file__).resolve()), "--describe-offline", str(out)],
|
|
cwd=ROOT, capture_output=True, text=True, timeout=120,
|
|
env={key: value for key, value in os.environ.items() if key != "AUSBOSS_COMFY_ROOT"},
|
|
)
|
|
if result.returncode or not out.exists():
|
|
raise AssertionError(f"The offline load failed:\n{result.stdout}\n{result.stderr}")
|
|
found = json.loads(out.read_text(encoding="utf-8"))
|
|
return found["api"], found["failed"]
|
|
|
|
|
|
def describe(pack) -> dict:
|
|
api = {}
|
|
for key, cls in sorted(pack.NODE_CLASS_MAPPINGS.items()):
|
|
inputs = cls.INPUT_TYPES()
|
|
types_ = [str(kind) if isinstance(kind, str) else "COMBO" for kind in cls.RETURN_TYPES]
|
|
names = list(getattr(cls, "RETURN_NAMES", None) or types_)
|
|
api[key] = {
|
|
"required": list(inputs.get("required", {})),
|
|
"optional": list(inputs.get("optional", {})),
|
|
"outputs": [[kind, name] for kind, name in zip(types_, names)],
|
|
}
|
|
return api
|
|
|
|
|
|
def breaking_changes(old: dict, new: dict) -> list[str]:
|
|
problems = []
|
|
for key, was in old.items():
|
|
now = new.get(key)
|
|
if now is None:
|
|
problems.append(f"{key}: removed")
|
|
continue
|
|
old_inputs = was["required"] + was["optional"]
|
|
new_inputs = now["required"] + now["optional"]
|
|
if new_inputs[: len(old_inputs)] != old_inputs:
|
|
problems.append(f"{key}: inputs {old_inputs} became {new_inputs}; only appending is safe")
|
|
added = [name for name in now["required"] if name not in was["required"]]
|
|
if added:
|
|
problems.append(f"{key}: {added} became required; API prompts without them stop validating")
|
|
if now["outputs"][: len(was["outputs"])] != was["outputs"]:
|
|
problems.append(f"{key}: outputs {was['outputs']} became {now['outputs']}; only appending is safe")
|
|
return problems
|
|
|
|
|
|
@unittest.skipUnless(COMFY_ROOT, "set AUSBOSS_COMFY_ROOT to a ComfyUI checkout")
|
|
class NodeApiTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.pack = load_pack()
|
|
cls.api = describe(cls.pack)
|
|
cls.snapshot = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
|
|
|
|
def test_every_node_module_loads(self):
|
|
self.assertEqual(self.pack._failed_modules, [])
|
|
|
|
def test_no_breaking_changes(self):
|
|
self.assertEqual(breaking_changes(self.snapshot, self.api), [])
|
|
|
|
def test_snapshot_is_current(self):
|
|
self.assertEqual(
|
|
self.snapshot, self.api,
|
|
"The node API grew compatibly; refresh the snapshot with --update (see this file's docstring).",
|
|
)
|
|
|
|
def test_the_offline_load_describes_the_pack_as_comfyui_does(self):
|
|
# OfflineSnapshotTests stand in for these tests in CI; this keeps
|
|
# the stand-ins honest.
|
|
offline, failed = describe_offline()
|
|
self.assertEqual(failed, [])
|
|
self.assertEqual(offline, self.api)
|
|
|
|
|
|
class OfflineSnapshotTests(unittest.TestCase):
|
|
"""The snapshot comparison without ComfyUI, so CI catches a renamed input."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.api, cls.failed = describe_offline()
|
|
cls.snapshot = json.loads(SNAPSHOT.read_text(encoding="utf-8"))
|
|
|
|
def test_every_node_module_loads(self):
|
|
self.assertEqual(self.failed, [])
|
|
|
|
def test_no_breaking_changes(self):
|
|
self.assertEqual(breaking_changes(self.snapshot, self.api), [])
|
|
|
|
def test_snapshot_is_current(self):
|
|
self.assertEqual(
|
|
self.snapshot, self.api,
|
|
"The node API grew compatibly; refresh the snapshot with --update (see this file's docstring).",
|
|
)
|
|
|
|
|
|
class BreakingChangeRuleTests(unittest.TestCase):
|
|
OLD = {"N": {"required": ["a", "b"], "optional": ["c"], "outputs": [["IMAGE", "image"]]}}
|
|
|
|
def check(self, old=None, **now):
|
|
old = old or self.OLD
|
|
return breaking_changes(old, {"N": {**old["N"], **now}})
|
|
|
|
def test_appending_an_optional_input_or_an_output_is_safe(self):
|
|
self.assertEqual(self.check(optional=["c", "d"]), [])
|
|
self.assertEqual(self.check(outputs=[["IMAGE", "image"], ["INT", "width"]]), [])
|
|
|
|
def test_a_new_required_input_breaks_api_prompts(self):
|
|
# Each case keeps the saved input order intact, so only the
|
|
# "became required" rule can catch it.
|
|
promoted = self.check(required=["a", "b", "c"], optional=[])
|
|
self.assertEqual(promoted, ["N: ['c'] became required; API prompts without them stop validating"])
|
|
no_optionals = {"N": {"required": ["a", "b"], "optional": [], "outputs": []}}
|
|
appended = self.check(no_optionals, required=["a", "b", "d"])
|
|
self.assertEqual(appended, ["N: ['d'] became required; API prompts without them stop validating"])
|
|
|
|
def test_reordering_renaming_or_removing_breaks(self):
|
|
self.assertTrue(self.check(required=["b", "a"]))
|
|
self.assertTrue(self.check(optional=[]))
|
|
self.assertTrue(self.check(outputs=[["IMAGE", "picture"]]))
|
|
self.assertTrue(breaking_changes(self.OLD, {}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--describe-offline" in sys.argv:
|
|
loaded = load_pack_offline()
|
|
target = Path(sys.argv[sys.argv.index("--describe-offline") + 1])
|
|
target.write_text(json.dumps({"api": describe(loaded), "failed": loaded._failed_modules}), encoding="utf-8")
|
|
elif "--update" in sys.argv:
|
|
if not COMFY_ROOT:
|
|
sys.exit("Set AUSBOSS_COMFY_ROOT to a ComfyUI checkout.")
|
|
allow_breaking = "--allow-breaking" in sys.argv # load_pack clears sys.argv
|
|
current = describe(load_pack())
|
|
if SNAPSHOT.exists():
|
|
problems = breaking_changes(json.loads(SNAPSHOT.read_text(encoding="utf-8")), current)
|
|
for problem in problems:
|
|
print(f"BREAKING: {problem}")
|
|
if problems and not allow_breaking:
|
|
sys.exit(
|
|
"Not written: saved workflows and API prompts would break. Restore the "
|
|
"inputs and outputs, or pass --allow-breaking for a node no release has shipped."
|
|
)
|
|
SNAPSHOT.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
|
print(f"Wrote {SNAPSHOT.relative_to(ROOT)} ({len(current)} nodes).")
|
|
else:
|
|
unittest.main()
|