Files
wildminder-ComfyUI-Chatterbox/tests/test_mm_api_surface.py
T
WildAi dc29843483 test: add mock-based suite
Vendored tree mocked at collection, so the suite runs
green without perth, diffusers, s3tokenizer or conformer.
Frozen widget snapshot pins node ids and widget order.
2026-10-06 18:36:08 +03:00

241 lines
10 KiB
Python

"""S7.4 -- ``comfy.model_management`` API drift guard.
Every call this package makes into ComfyUI's memory manager is a **bare
attribute access on a module object**, so a core rename does not fail at
import time. It fails when a user runs the node -- on their machine, with
their model, mid-prompt -- as an ``AttributeError`` from inside a third-party
custom node. That is the worst place it can fail: after the download, after
the VRAM decision, with no pointer back to the line that has to change.
So the surface is pinned here instead. This is a **guard, not a mock**: it
imports the real ``comfy.model_management`` out of the real ComfyUI checkout
that ``conftest.py`` puts on ``sys.path``, and asserts the names are there. A
ComfyUI upgrade that renames one of them turns this file red at the suite
level, where it costs nothing, instead of red in production.
Two things are asserted that a bare ``hasattr`` sweep cannot:
* :class:`comfy.model_management.InterruptProcessingException` really does
derive from ``BaseException`` and not from ``Exception``. That single fact is
what makes ``except Exception`` in the nodes and in
:func:`modules.generation.generate_audio` unable to swallow a cancellation.
If core ever re-parented it, every "interrupt returns silence" clause would
silently become unreachable and a cancelled run would be reported as a
synthesis failure.
* Every symbol named here is also actually *reached* by the shipped package,
so the list cannot rot into a list of names nothing calls any more. The one
deliberate exception (``unet_offload_device``, ``module_size``) is named in
:data:`DECLARED_BUT_NOT_CALLED` with the reason.
"""
from __future__ import annotations
import ast
import os
import pytest
from conftest import ROOT_DIR
#: Every ``comfy.model_management`` symbol this package depends on. Order is the
#: plan's (§Phase 7, S7.4), which groups them: device resolution, memory
#: placement, dtype policy, bookkeeping, and interruption.
MM_SYMBOLS: tuple[str, ...] = (
"get_torch_device",
"get_gpu_device_options",
"resolve_gpu_device_option",
"unet_offload_device",
"intermediate_device",
"load_model_gpu",
"load_models_gpu",
"get_free_memory",
"soft_empty_cache",
"should_use_fp16",
"should_use_bf16",
"get_autocast_device",
"module_size",
"unload_all_models",
"InterruptProcessingException",
"throw_exception_if_processing_interrupted",
)
#: Symbols the plan lists that the shipped package does not currently *call*.
#:
#: They are kept in :data:`MM_SYMBOLS` on purpose. ``unet_offload_device`` and
#: ``module_size`` are the two an upstream core refactor renames first, and a
#: guard that only pins what is used today would not notice the rename until
#: the day the code starts calling them. Asserting "these two are not called
#: yet" keeps that gap visible instead of silent.
DECLARED_BUT_NOT_CALLED: frozenset[str] = frozenset(
{"unet_offload_device", "module_size"}
)
#: Shipped Python, minus the vendored tree (imported by absolute name, mocked in
#: collection, and upstream code that may log however upstream logs) and the
#: test suite (which discusses the idiom rather than following it).
SCANNED_ROOTS: tuple[str, ...] = ("modules", "nodes")
SCANNED_FILES: tuple[str, ...] = ("chatterbox_nodes.py", "__init__.py")
EXCLUDED_DIRS: frozenset[str] = frozenset({"__pycache__"})
def _iter_shipped_sources() -> list[str]:
"""Repo-relative POSIX paths of every shipped Python file."""
found: list[str] = []
for root in SCANNED_ROOTS:
base = os.path.join(ROOT_DIR, root)
for dirpath, dirnames, filenames in os.walk(base):
dirnames[:] = sorted(
name for name in dirnames if name not in EXCLUDED_DIRS
)
for filename in sorted(filenames):
if filename.endswith(".py"):
relative = os.path.relpath(
os.path.join(dirpath, filename), ROOT_DIR
)
found.append(relative.replace(os.sep, "/"))
for name in SCANNED_FILES:
found.append(name)
return sorted(found)
def _read(relative: str) -> str:
with open(os.path.join(ROOT_DIR, relative), "r", encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def model_management():
"""The real ``comfy.model_management``, imported from the real checkout.
Deliberately **not** mocked. A guard that asserts against a stand-in proves
only that the stand-in has the names, which is exactly the failure mode it
exists to prevent.
"""
import comfy.model_management
return comfy.model_management
class TestSymbolsExist:
@pytest.mark.parametrize("symbol", MM_SYMBOLS)
def test_symbol_is_present_in_core(self, model_management, symbol: str) -> None:
assert hasattr(model_management, symbol), (
f"comfy.model_management.{symbol} no longer exists. This package "
f"calls it, and a missing attribute is an AttributeError raised "
f"inside a custom node at prompt time, not at install time. Either "
f"adapt modules/device_utils.py or modules/patcher.py to the new "
f"name, or update MM_SYMBOLS here if the dependency went away."
)
def test_the_list_is_not_shrinking_under_pressure(self, model_management) -> None:
"""One sweep, so a reader can see the whole surface at a glance."""
missing = [name for name in MM_SYMBOLS if not hasattr(model_management, name)]
assert missing == [], f"comfy.model_management is missing: {missing}"
def test_the_interrupt_is_not_an_exception_subclass(self, model_management) -> None:
"""``except Exception`` must not be able to swallow a cancellation.
Every "interrupt returns silence, no traceback" clause in the node
layer is only reachable because core's interrupt bypasses
``Exception``. Re-parenting it under ``Exception`` would turn those
clauses into dead code and a cancelled run into a reported error.
"""
interrupt = model_management.InterruptProcessingException
assert issubclass(interrupt, BaseException)
assert not issubclass(interrupt, Exception), (
"comfy.model_management.InterruptProcessingException now derives "
"from Exception; the `except Exception` clauses in "
"modules/generation.py would swallow it before the "
"interrupt clause could re-raise it."
)
def test_the_interrupt_is_importable_by_the_name_we_probe(
self, model_management
) -> None:
"""``modules.audio_utils.interrupt_exception_class`` probes by name.
It is a ``getattr`` with a ``BaseException`` fallback so collection
never depends on core being importable, which means a rename would
silently degrade to "everything is an interrupt". Pin the attribute
path the probe uses.
"""
candidate = getattr(
model_management, "InterruptProcessingException", None
)
assert isinstance(candidate, type)
assert candidate is model_management.InterruptProcessingException
class TestSurfaceIsActuallyUsed:
"""The list must track the code, or it is decoration."""
def test_the_scan_finds_the_package(self) -> None:
files = _iter_shipped_sources()
assert len(files) >= 10, (
f"the scan only found {files}; it cannot be trusted to prove the "
f"package uses (or does not use) a core symbol"
)
@pytest.mark.parametrize(
"symbol", sorted(set(MM_SYMBOLS) - DECLARED_BUT_NOT_CALLED)
)
def test_the_symbol_is_reached_by_shipped_code(self, symbol: str) -> None:
mentions = [
relative
for relative in _iter_shipped_sources()
if symbol in _read(relative)
]
assert mentions, (
f"{symbol} is pinned in MM_SYMBOLS but nothing in the package "
f"names it any more. Drop it from MM_SYMBOLS (and, if it was a "
f"real dependency, from the plan) instead of leaving the guard "
f"asserting a name that protects nothing."
)
def test_the_unused_declarations_are_still_unused(self) -> None:
"""Keep the declared-but-uncalled gap honest in both directions.
If one of these starts being called, the entry is stale -- but that is
not a failure, it is progress, and ``MM_SYMBOLS`` already covers it.
What must not happen is the *silent* case: the name being pinned while
the call site is spelled differently, so a rename would slip past.
"""
for symbol in sorted(DECLARED_BUT_NOT_CALLED):
assert symbol in MM_SYMBOLS
for relative in _iter_shipped_sources():
tree = ast.parse(_read(relative), filename=relative)
called = any(
isinstance(node, ast.Attribute) and node.attr == symbol
for node in ast.walk(tree)
)
assert not called, (
f"{symbol} is called in {relative} but is still declared "
f"uncalled in DECLARED_BUT_NOT_CALLED"
)
def test_modules_never_import_the_memory_manager_at_module_scope(
self,
) -> None:
"""``modules/`` reaches core lazily, through ``_get_model_management``.
Not a style rule: ``comfy.model_management`` imports torch and probes
the accelerator at module scope, and this package is also imported by
``/object_info`` while ComfyUI is still starting up.
"""
offenders = []
for relative in _iter_shipped_sources():
if not relative.startswith("modules/"):
continue
tree = ast.parse(_read(relative), filename=relative)
for node in tree.body: # module scope only
names = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom):
names = [node.module or ""]
if any(name.startswith("comfy.model_management") for name in names):
offenders.append(f"{relative}:{node.lineno}")
assert offenders == [], (
f"these modules import comfy.model_management at module scope: "
f"{offenders}. Use modules.device_utils._get_model_management()."
)