Files
wildminder-ComfyUI-KEEP/tests/test_keep_console_routing.py
T
WildAi 317f503c6d feat(keep): make Cancel work and restore every detected face
Two owner-reported bugs, both verified as gaps that never closed (the old
code on master had them too).

Cancel: the node never polled ComfyUI's interrupt flag, so the Cancel
button had no effect. A new src/keep_interrupt.py wraps
comfy.model_management.throw_exception_if_processing_interrupted() (lazy
import, a no-op outside ComfyUI); it is called at every long-loop
boundary - detection, crop, restore chunks, paste frames, and the paste
module's group/chunk loops. InterruptProcessingException is a
BaseException, so it escapes the pipeline untouched.

Multi-face: the pipeline collapsed to one face per frame - the detector
call passed only_keep_largest=True (the vendored selector reduces to the
largest face) and the processor then took all_landmarks_5[0]. With
only_center_face=False the new branches keep every detected face:
detection returns all faces, the crops/restore/paste flatten to the
ragged per-frame face count, the device canvas composites several ROIs
per row, and the vendored fallback hands the helper all of a frame's
faces. The near-black tail follows the real vendored reference (one
convert per image after a forward face loop). only_center_face=True and
the aligned path keep their bodies byte-for-byte.

44 new pins: cancel (the exception escapes the sequence and the node, no
work after it, unchanged output without a cancel), multi-face image and
video (all faces restored and pasted, faces appearing/disappearing,
None-affine and black-face drops, per-row near-black tail against the
vendored composite), and the paste core (face-to-frame map, row
compositing, chunk boundaries that never split a frame's faces).

Verified on the CPU device only - no model loads, no GPU runs. Gate: 505
passed, 6 skipped, 7 deselected, 0 failed (461 baseline + 44 new pins).
2026-10-28 18:11:25 +03:00

367 lines
13 KiB
Python

"""The console routing of the pack - every console write reaches the logger.
The vendored facelib writes to the console with ``print()``. Those writes
bypass the console of ComfyUI: they are untagged and they never reach the
log file of the host. ``src/keep_console.py`` routes them to the logger of
the pack:
* the writes of the **owning thread** become log records - one record per
complete line - and the tag filter adds the ``[KEEP]`` tag;
* the writes of **another thread** are forwarded to the original stream
untouched;
* the streams are restored when the block ends - also after an exception;
* the output of ComfyUI is not swallowed: its root handler holds the stream
object it was built with and writes straight through the swap.
The file also carries the guards of the **tqdm removal**: the processor must
not import ``tqdm`` any more - the progress of the node is reported through
the ComfyUI execution API (``src/keep_progress.py``).
"""
from __future__ import annotations
import ast
import contextlib
import importlib
import io
import logging
import sys
import threading
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = REPO_ROOT / "src"
@pytest.fixture(scope="module")
def console_module():
return importlib.import_module("ComfyUI_KEEP.src.keep_console")
@pytest.fixture(scope="module")
def logger_module():
return importlib.import_module("ComfyUI_KEEP.src.keep_logger")
class _Capture(logging.Handler):
"""A handler that keeps the records it receives."""
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
@contextlib.contextmanager
def _records(target: logging.Logger):
"""The records the logger receives while the block runs."""
handler = _Capture()
target.addHandler(handler)
try:
yield handler.records
finally:
target.removeHandler(handler)
def _pack_logger(logger_module, name: str) -> logging.Logger:
"""A **dedicated** pack logger - never the global one.
The name carries no dot on purpose: a dotted name would make the logger
a child of ``ComfyUI-KEEP`` and the records would travel through the
global pack logger.
"""
return logger_module.configure_logger(logging.getLogger(name), comfyui_present=True)
def _messages(records) -> list[str]:
"""The messages of the records."""
return [record.getMessage() for record in records]
# ---------------------------------------------------------------------------
# the capture
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_a_print_inside_the_block_becomes_a_tagged_record(
console_module, logger_module
):
"""The console write of the owning thread reaches the logger."""
logger = _pack_logger(logger_module, "KEEP-test-console-print")
with _records(logger) as records:
with console_module.capture_console_to_logger(logger):
print("hello from the vendored tree")
assert _messages(records) == ["[KEEP] hello from the vendored tree"]
@pytest.mark.unit
def test_the_default_logger_is_the_logger_of_the_pack(console_module, logger_module):
"""Without an argument the pack routes to the logger of the pack."""
assert console_module.logger is logger_module.logger
with _records(logger_module.logger) as records:
with console_module.capture_console_to_logger():
print("hello")
assert _messages(records) == ["[KEEP] hello"]
@pytest.mark.unit
def test_a_partial_write_is_buffered_until_the_line_is_complete(
console_module, logger_module
):
"""One record per **complete** line - a partial line waits for its newline."""
logger = _pack_logger(logger_module, "KEEP-test-console-partial")
with _records(logger) as records:
with console_module.capture_console_to_logger(logger):
sys.stdout.write("first ")
assert _messages(records) == [], "an incomplete line must not be logged"
sys.stdout.write("line\n")
sys.stdout.write("second line\n")
assert _messages(records) == ["[KEEP] first line", "[KEEP] second line"]
@pytest.mark.unit
def test_a_line_without_a_newline_is_flushed_at_the_end_of_the_block(
console_module, logger_module
):
"""The output of a ``print(..., end="")`` is not lost."""
logger = _pack_logger(logger_module, "KEEP-test-console-flush")
with _records(logger) as records:
with console_module.capture_console_to_logger(logger):
sys.stdout.write("no newline")
assert _messages(records) == ["[KEEP] no newline"]
@pytest.mark.unit
def test_the_stderr_of_the_owning_thread_is_captured_too(
console_module, logger_module
):
"""``sys.stderr`` is captured as well - as a warning."""
logger = _pack_logger(logger_module, "KEEP-test-console-stderr")
with _records(logger) as records:
with console_module.capture_console_to_logger(logger):
print("on stderr", file=sys.stderr)
assert len(records) == 1
assert records[0].getMessage() == "[KEEP] on stderr"
assert records[0].levelno == logging.WARNING
# ---------------------------------------------------------------------------
# the thread scope
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_the_write_of_another_thread_is_not_captured(
console_module, logger_module, monkeypatch
):
"""Another thread keeps the console - its write is forwarded untouched."""
logger = _pack_logger(logger_module, "KEEP-test-console-thread")
original = io.StringIO()
monkeypatch.setattr(sys, "stdout", original)
inside = threading.Event()
done = threading.Event()
def _worker():
inside.wait(5)
print("from the worker")
done.set()
with _records(logger) as records:
with console_module.capture_console_to_logger(logger):
worker = threading.Thread(target=_worker)
worker.start()
inside.set()
assert done.wait(5), "the worker did not finish"
worker.join(5)
assert _messages(records) == [], "the write of the worker must not be captured"
assert original.getvalue() == "from the worker\n"
@pytest.mark.unit
def test_two_threads_capture_into_their_own_logger(
console_module, logger_module
):
"""Two capture blocks may run at the same time - each one owns its logger."""
logger_a = _pack_logger(logger_module, "KEEP-test-console-parallel-a")
logger_b = _pack_logger(logger_module, "KEEP-test-console-parallel-b")
inside_a = threading.Event()
inside_b = threading.Event()
errors: list[BaseException] = []
def _run(name, target, mine, other):
try:
with console_module.capture_console_to_logger(target):
mine.set()
other.wait(5)
print(f"from {name}")
except BaseException as exc: # noqa: BLE001 - the failure is asserted below
errors.append(exc)
with _records(logger_a) as records_a, _records(logger_b) as records_b:
thread_a = threading.Thread(target=_run, args=("a", logger_a, inside_a, inside_b))
thread_a.start()
assert inside_a.wait(5), "the first thread did not enter the block"
thread_b = threading.Thread(target=_run, args=("b", logger_b, inside_b, inside_a))
thread_b.start()
thread_a.join(5)
thread_b.join(5)
assert errors == []
assert _messages(records_a) == ["[KEEP] from a"]
assert _messages(records_b) == ["[KEEP] from b"]
# ---------------------------------------------------------------------------
# the restoration
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_the_streams_are_restored_after_the_block(console_module, logger_module):
"""The block leaves the streams of the process as it found them."""
logger = _pack_logger(logger_module, "KEEP-test-console-restore")
before = (sys.stdout, sys.stderr)
with console_module.capture_console_to_logger(logger):
assert (sys.stdout, sys.stderr) != before
assert (sys.stdout, sys.stderr) == before
@pytest.mark.unit
def test_the_streams_are_restored_after_an_exception(
console_module, logger_module
):
"""An exception inside the block does not leave the streams swapped."""
logger = _pack_logger(logger_module, "KEEP-test-console-restore-error")
before = (sys.stdout, sys.stderr)
with pytest.raises(RuntimeError):
with console_module.capture_console_to_logger(logger):
raise RuntimeError("boom")
assert (sys.stdout, sys.stderr) == before
@pytest.mark.unit
def test_a_nested_block_restores_the_outer_one(console_module, logger_module):
"""The blocks may be nested - the inner one does not break the outer."""
logger = _pack_logger(logger_module, "KEEP-test-console-nested")
outer = _pack_logger(logger_module, "KEEP-test-console-nested-outer")
before = (sys.stdout, sys.stderr)
with _records(logger) as records_inner, _records(outer) as records_outer:
with console_module.capture_console_to_logger(outer):
with console_module.capture_console_to_logger(logger):
print("inner")
print("outer")
assert (sys.stdout, sys.stderr) == before
assert _messages(records_outer) == ["[KEEP] outer"]
assert _messages(records_inner) == ["[KEEP] inner"]
# ---------------------------------------------------------------------------
# the output of the host
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_the_output_of_comfyui_is_not_swallowed(
console_module, logger_module, monkeypatch
):
"""The root handler of the host holds its own stream - it is not routed.
``app.logger.setup_logger()`` installs the ``LogInterceptor`` before it
builds the root handler, so the handler writes straight through the swap.
"""
logger = _pack_logger(logger_module, "KEEP-test-console-comfy")
original = io.StringIO()
monkeypatch.setattr(sys, "stdout", original)
monkeypatch.setattr(sys, "stderr", original)
handler = logging.StreamHandler(original)
host_logger = logging.getLogger("KEEP-test-comfy-host")
host_logger.setLevel(logging.WARNING)
host_logger.addHandler(handler)
try:
with console_module.capture_console_to_logger(logger):
host_logger.warning("comfy says hello")
finally:
host_logger.removeHandler(handler)
assert "comfy says hello" in original.getvalue()
# ---------------------------------------------------------------------------
# the tqdm removal
# ---------------------------------------------------------------------------
def _imported_modules(path: Path) -> set[str]:
"""The top level modules a source file imports."""
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
imported: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module.split(".")[0])
return imported
@pytest.mark.unit
def test_the_processor_does_not_import_tqdm():
"""The progress bars of the processor are gone - the pack logs its steps."""
source = SRC_DIR / "keep_processor.py"
assert "tqdm" not in _imported_modules(source)
assert "tqdm(" not in source.read_text(encoding="utf-8")
@pytest.mark.unit
def test_the_processor_logs_the_four_steps():
"""Each sequence path logs its four steps - one message each, in order."""
source = (SRC_DIR / "keep_processor.py").read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(SRC_DIR / "keep_processor.py"))
four_steps = [
"Detecting face landmarks",
"Cropping and aligning faces",
"Restoring faces with KEEP",
"Pasting faces and finalizing frames",
]
def _stage_messages(fn):
calls = [
node
for node in ast.walk(fn)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "info"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "logger"
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
]
messages = [node.args[0].value for node in sorted(calls, key=lambda call: call.lineno)]
return [m for m in messages if m in four_steps]
paths = [
node.name
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and _stage_messages(node)
]
assert paths, "no sequence path logs its four steps"
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in paths:
assert _stage_messages(node) == four_steps, node.name