Files
wildminder-ComfyUI-Chatterbox/tests/test_error_handling.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

766 lines
30 KiB
Python

"""S7.1 -- errors raise; only the legitimate empty case is silence.
The V1 nodes had six sites -- ``nodes.py:136,159,203,260,283,317`` -- that
wrapped a whole node body in ``except Exception`` and returned one second of
silence. The failure mode is not that the run stops: it is that the run
**appears to succeed**. A graph wired to a preview, a batch of fifty prompts,
a render queue that reports "done" -- every one of them looks clean, and the
audio that comes out is silence. A user debugging that has no error, no log
line naming the pack, and no way to tell a cancelled run from a broken one.
So the contract this file pins is:
* **Empty text is silence**, and only that. A graph wired up before it is
finished runs; one second of silence is the honest answer for "there was
nothing to say".
* **A generation failure raises**, with the model-pack name in the message.
The pack name is the one piece of information that tells the user which
download or checkpoint to look at.
* **A cancellation is silence with no traceback.** ``InterruptProcessingException``
derives from ``BaseException``, so only the clause that names it can catch it,
and it logs at INFO with no ``exc_info``.
* **The old pattern has no surviving test.** Not "is not currently true" --
*no test in this repository asserts it*. A regression guard that permits the
V1 behaviour is how the behaviour comes back; see
:class:`TestNoSurvivingTestOfTheOldBehaviour`.
The last class is the one that earns this file's keep. Everything above it
describes the code; that class describes the tests, and a codebase whose tests
pin the correct behaviour is what makes the correct behaviour the one that
survives a refactor.
"""
from __future__ import annotations
import ast
import inspect
import os
import pytest
import torch
from ComfyUI_Chatterbox.modules import generation as generation_module
from ComfyUI_Chatterbox.modules.audio_utils import interrupt_exception_class
from ComfyUI_Chatterbox.modules.generation import generate_audio
from ComfyUI_Chatterbox.nodes import ChatterboxTTSNode, ChatterboxVCNode
from ComfyUI_Chatterbox.nodes import tts_node as tts_node_module
from ComfyUI_Chatterbox.nodes import vc_node as vc_node_module
from conftest import ROOT_DIR
# ---------------------------------------------------------------------------
# Doubles
# ---------------------------------------------------------------------------
class StubPack:
"""A loaded pack whose ``generate`` outcome the test decides."""
def __init__(self, error: BaseException | None = None, samples: int = 24000) -> None:
self.error = error
self.samples = samples
self.t3 = torch.nn.Linear(4, 4)
self.s3gen = torch.nn.Linear(4, 4)
self.ve = torch.nn.Linear(2, 2)
self.calls: list[dict] = []
def generate(self, *args, **kwargs) -> torch.Tensor:
self.calls.append({"args": list(args), **kwargs})
if self.error is not None:
raise self.error
return torch.zeros((1, self.samples))
class FakeInterrupt(BaseException):
"""A stand-in for core's interrupt, which also derives from ``BaseException``."""
#: Core's real interrupt class, used wherever a test has to reproduce what a
#: cancelled run actually raises. The node layer catches *that* class by name
#: (``interrupt_exception_class()``), so a stand-in would simply propagate --
#: which is correct, and useless for testing the silence path.
REAL_INTERRUPT = interrupt_exception_class()
#: A cancellation that also derives from ``RuntimeError``.
#:
#: Core's real class derives from ``BaseException`` only, so ``except Exception``
#: cannot see it and the ordering of the two clauses would not matter. This one
#: removes that guarantee, which is exactly why the interrupt clause has to be
#: written *first* rather than merely *present*: with the clauses swapped, an
#: exception deriving from both would be reported as a generation failure and
#: the run would not cancel.
HybridInterrupt = type(
"HybridInterrupt", (REAL_INTERRUPT, RuntimeError), {"__module__": __name__}
)
def interrupted(message: str = "cancelled") -> BaseException:
"""A fresh cancellation instance, for a stub that raises it."""
return REAL_INTERRUPT(message)
@pytest.fixture(autouse=True)
def _no_progress_bar(monkeypatch):
"""Drop the real ``comfy.utils.ProgressBar`` plumbing from the walk.
``generate_audio`` builds one and ticks it thousands of times over a CPU
run; here every pack either returns instantly or raises on the first call,
so the bar is noise. The interrupt class is left alone -- that is the thing
under test.
"""
monkeypatch.setattr(
generation_module,
"_build_progress_bar",
lambda total: generation_module.InterruptProgressBar(total, None),
)
def node_kwargs(**overrides) -> dict:
"""A minimal, complete ``ChatterboxTTSNode.execute`` call."""
kwargs = {
"model_pack_name": "resembleai_default_voice",
"text": "Hello there.",
"max_new_tokens": 1000,
"flow_cfg_scale": 0.7,
"exaggeration": 0.5,
"temperature": 0.8,
"cfg_weight": 0.5,
"repetition_penalty": 1.2,
"min_p": 0.05,
"top_p": 1.0,
"seed": 0,
"use_watermark": False,
}
kwargs.update(overrides)
return kwargs
@pytest.fixture
def stubbed_generation(monkeypatch):
"""Replace the model load and the synthesis, keep the node's own code."""
def _install(model, loader_result=None):
loader_result = loader_result or (None, model, None)
monkeypatch.setattr(
tts_node_module,
"load_chatterbox_model",
lambda *args, **kwargs: loader_result,
)
monkeypatch.setattr(
tts_node_module, "generate_audio", lambda model, text, **kwargs: (
model.generate(text)
)
)
monkeypatch.setattr(
vc_node_module,
"load_chatterbox_model",
lambda *args, **kwargs: loader_result,
)
monkeypatch.setattr(
vc_node_module, "convert_voice_audio", lambda model, audio, **kwargs: (
model.generate(audio)
)
)
return _install
# ---------------------------------------------------------------------------
# Empty text: the one legitimate silence
# ---------------------------------------------------------------------------
class TestEmptyTextIsSilence:
@pytest.mark.parametrize("blank", ["", " ", "\n\t", " \r\n "])
def test_the_node_returns_silence(self, blank, stubbed_generation) -> None:
stubbed_generation(StubPack())
result = ChatterboxTTSNode.execute(**node_kwargs(text=blank))
audio = result.result[0]
assert audio["sample_rate"] == 24000
assert audio["waveform"].abs().max().item() == 0.0, (
"the empty-text path must return actual silence, not a waveform "
"that merely happens to be short"
)
assert audio["waveform"].shape[-1] == 24000, "one second, by convention"
def test_an_empty_box_does_not_load_a_model(self, monkeypatch) -> None:
"""2.7 GB is too expensive a price for an empty text box.
The check has to come before ``load_chatterbox_model``; a version that
loads first and returns silence afterwards still "works", and still
makes every half-wired graph download a checkpoint.
"""
called: list[tuple] = []
monkeypatch.setattr(
tts_node_module,
"load_chatterbox_model",
lambda *args, **kwargs: called.append(kwargs) or (None, None, None),
)
ChatterboxTTSNode.execute(**node_kwargs(text=" "))
assert called == [], (
f"the empty-text path loaded a model ({called}); it must return "
f"before the load"
)
def test_generation_still_refuses_blank_text_directly(self) -> None:
"""The node's silence does not weaken the layer under it.
``generate_audio`` is also the API entry point, and an API caller that
sends blank text has made a mistake worth reporting. Only the node
translates "unfinished graph" into silence.
"""
with pytest.raises(ValueError, match="needs some text"):
generate_audio(StubPack(), " ")
def test_blank_text_is_not_the_only_thing_the_node_catches(self) -> None:
"""The silence is one explicit check, not a blanket handler."""
source = inspect.getsource(tts_node_module.ChatterboxTTSNode.execute)
assert "text.strip()" in source
assert "except Exception" not in source, (
"a blanket handler is back; see TestNoSurvivingTestOfTheOldBehaviour"
)
# ---------------------------------------------------------------------------
# Failures raise
# ---------------------------------------------------------------------------
class TestFailuresRaise:
def test_a_generate_failure_reaches_the_caller(
self, stubbed_generation
) -> None:
stubbed_generation(StubPack(error=RuntimeError("CUDA out of memory")))
with pytest.raises(RuntimeError, match="CUDA out of memory"):
ChatterboxTTSNode.execute(**node_kwargs())
def test_the_pack_name_is_available_to_the_message(
self, stubbed_generation, monkeypatch
) -> None:
"""A failing load names the pack.
That is the one fact that tells the user which download or checkpoint to
go and look at, and the V1 code threw it away along with everything
else.
"""
monkeypatch.setattr(
tts_node_module,
"load_chatterbox_model",
lambda *args, **kwargs: (_ for _ in ()).throw(
RuntimeError(
"The Chatterbox voice pack 's3gen-multilingual' could not be "
"loaded. Check the log above for the download or build failure."
)
),
)
with pytest.raises(RuntimeError, match="s3gen-multilingual"):
ChatterboxTTSNode.execute(
**node_kwargs(model_pack_name="s3gen-multilingual")
)
def test_a_conversion_failure_raises_too(self, monkeypatch) -> None:
pack = StubPack(error=ValueError("bad reference clip"))
monkeypatch.setattr(
vc_node_module, "load_chatterbox_model", lambda *a, **k: (None, None, pack)
)
monkeypatch.setattr(
vc_node_module,
"convert_voice_audio",
lambda model, audio, **kwargs: model.generate(audio),
)
with pytest.raises(ValueError, match="bad reference clip"):
ChatterboxVCNode.execute(
model_pack_name="resembleai_default_voice",
source_audio={
"waveform": torch.zeros((1, 1, 2400)),
"sample_rate": 24000,
},
n_timesteps=10,
temperature=1.0,
flow_cfg_scale=0.7,
use_watermark=False,
)
def test_the_pack_without_the_right_half_raises(self, monkeypatch) -> None:
"""A TTS pack with no synthesis model is a wiring mistake, not silence."""
monkeypatch.setattr(
tts_node_module,
"load_chatterbox_model",
lambda *args, **kwargs: (None, None, None),
)
with pytest.raises(ValueError, match="resembleai_default_voice"):
ChatterboxTTSNode.execute(**node_kwargs())
# ---------------------------------------------------------------------------
# Cancellation: silence, no traceback
# ---------------------------------------------------------------------------
class TestCancellation:
def test_an_interrupt_returns_silence(self, stubbed_generation) -> None:
stubbed_generation(StubPack(error=interrupted()))
result = ChatterboxTTSNode.execute(**node_kwargs())
assert result.result[0]["waveform"].abs().max().item() == 0.0
def test_an_interrupt_logs_no_traceback(
self, stubbed_generation, caplog
) -> None:
"""A cancelled run is not a failed run.
``logging.exception`` / ``exc_info=True`` here would print a stack trace
for something the user asked for, which trains them to ignore the ones
that matter.
"""
import logging
stubbed_generation(StubPack(error=interrupted()))
with caplog.at_level(logging.DEBUG, logger=""):
ChatterboxTTSNode.execute(**node_kwargs())
assert caplog.records, "the cancellation was not logged at all"
for record in caplog.records:
assert record.exc_info is None, (
f"a cancellation was logged with a traceback: {record.getMessage()}"
)
assert any(
"cancel" in record.getMessage().lower() for record in caplog.records
), f"no cancellation notice in {[r.getMessage() for r in caplog.records]}"
def test_an_interrupt_propagates_out_of_generation(self) -> None:
"""``generate_audio`` re-raises; only the node turns it into silence.
Swallowing the interrupt inside ``generate_audio`` would end the prompt
cleanly but leave ComfyUI's interrupt flag set, so the *next* node in
the queue would cancel too.
"""
with pytest.raises(REAL_INTERRUPT):
generate_audio(StubPack(error=interrupted()), "hello")
def test_a_hybrid_interrupt_is_not_caught_by_a_bare_exception(
self,
) -> None:
"""The case that proves the clause ordering rather than its presence.
``HybridInterrupt`` derives from core's interrupt **and** from
``RuntimeError``. An ``except Exception`` clause would therefore catch
it; the interrupt clause, which appears first, wins. With the two
clauses swapped, the interrupt would be logged as a generation failure
and re-raised as a plain error -- the run would not cancel.
"""
error = HybridInterrupt("cancelled")
with pytest.raises(REAL_INTERRUPT):
generate_audio(StubPack(error=error), "hello")
assert isinstance(error, RuntimeError), (
"the test double stopped deriving from RuntimeError, which is the "
"whole point of it"
)
def test_a_generic_failure_still_raises_after_the_interrupt_clause(
self,
) -> None:
"""The interrupt clause must not have become ``except BaseException``."""
with pytest.raises(RuntimeError, match="out of memory"):
generate_audio(StubPack(error=RuntimeError("out of memory")), "hello")
def test_the_real_interrupt_class_is_base_exception_derived(self) -> None:
"""Against the real core, not a double."""
interrupt = interrupt_exception_class()
assert issubclass(interrupt, BaseException)
assert not issubclass(interrupt, Exception), (
"core re-parented the interrupt under Exception; every "
"`except Exception` in the package can now swallow a cancellation"
)
# ---------------------------------------------------------------------------
# The old behaviour has no surviving test
# ---------------------------------------------------------------------------
#: The shape the V1 nodes were written in: a whole node body wrapped so that
#: every failure becomes one second of silence.
OLD_PATTERN_AST_HINT = "ExceptHandler(type=Exception) -> returns silence"
SHIPPED_TREE = ("nodes", "modules", "chatterbox_nodes.py", "__init__.py")
def _iter_shipped_sources() -> list[str]:
found: list[str] = []
for root in SHIPPED_TREE:
path = os.path.join(ROOT_DIR, root)
if os.path.isfile(path):
found.append(root)
continue
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [name for name in dirnames if name != "__pycache__"]
for filename in sorted(filenames):
if filename.endswith(".py"):
found.append(
os.path.relpath(
os.path.join(dirpath, filename), ROOT_DIR
).replace(os.sep, "/")
)
return sorted(found)
def _iter_test_sources() -> list[str]:
directory = os.path.join(ROOT_DIR, "tests")
return sorted(
os.path.join(directory, name)
for name in os.listdir(directory)
if name.endswith(".py")
)
#: Entry points a test can call and get a return value out of.
ENTRY_POINTS: frozenset[str] = frozenset(
{"execute", "generate_audio", "convert_voice_audio", "load_chatterbox_model"}
)
#: Exception classes available without being defined in the test module.
BUILTIN_EXCEPTIONS: frozenset[str] = frozenset(
{
"Exception",
"BaseException",
"RuntimeError",
"ValueError",
"TypeError",
"OSError",
"IOError",
"KeyError",
"IndexError",
"AttributeError",
"FileNotFoundError",
"NotImplementedError",
"AssertionError",
"ArithmeticError",
"ZeroDivisionError",
}
)
#: A class whose name carries this is a cancellation, not a failure. Silent
#: handling of a cancellation is the contract, so a test may assert silence
#: after one; a test may not after anything else.
CANCELLATION_MARKER = "interrupt"
def _module_exception_classes(tree: ast.Module) -> dict[str, frozenset[str]]:
"""``name -> base names`` for every exception class defined in ``tree``."""
defined: dict[str, frozenset[str]] = {}
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
if not any(
isinstance(base, ast.Name) and base.id in BUILTIN_EXCEPTIONS
for base in node.bases
):
continue
defined[node.name] = frozenset(
base.id for base in node.bases if isinstance(base, ast.Name)
)
return defined
def _is_generic_failure(class_name: str, defined: dict[str, frozenset[str]]) -> bool:
"""Whether ``class_name`` is an ``Exception``-derived, non-cancellation error.
A cancellation is exempt: ``InterruptProcessingException`` derives from
``BaseException``, the stub names carry "interrupt", and silence after a
cancellation is exactly what the node layer is supposed to do.
"""
if CANCELLATION_MARKER in class_name.lower():
return False
bases = defined.get(class_name, frozenset({class_name}) & BUILTIN_EXCEPTIONS)
if not bases:
bases = frozenset({class_name}) & BUILTIN_EXCEPTIONS
return bool(bases - {"BaseException"})
def _stubbed_failures(node: ast.AST, defined: dict[str, frozenset[str]]) -> set[str]:
"""Exception class names this test *builds an instance of*.
``StubPack(error=RuntimeError(...))``, ``pytest.raises(ValueError)`` and a
bare ``ValueError(...)`` all count: each is a test asking for a failure.
"""
found: set[str] = set()
for child in ast.walk(node):
if isinstance(child, ast.Call):
name = None
if isinstance(child.func, ast.Name):
name = child.func.id
elif isinstance(child.func, ast.Attribute):
name = child.func.attr
if name and _is_generic_failure(name, defined):
found.add(name)
return found
def _calls_entry_point(node: ast.AST, defined: dict[str, frozenset[str]]) -> list[str]:
"""Entry-point calls in ``node`` that are not inside ``pytest.raises``."""
calls: list[str] = []
for child in ast.walk(node):
if not isinstance(child, ast.Call) or not isinstance(child.func, ast.Attribute):
continue
if child.func.attr not in ENTRY_POINTS:
continue
# Walk up is not available from a bare Call node, so the enclosing
# `with` is handled by the caller slicing the body instead.
calls.append(ast.unparse(child))
return calls
def _inside_pytest_raises(tree: ast.FunctionDef) -> list[str]:
"""Entry-point call sources that appear under a ``with pytest.raises``."""
guarded: list[str] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.With, ast.AsyncWith)):
continue
text = ast.unparse(node)
if "pytest.raises" not in text:
continue
for child in ast.walk(node):
if (
isinstance(child, ast.Call)
and isinstance(child.func, ast.Attribute)
and child.func.attr in ENTRY_POINTS
):
guarded.append(ast.unparse(child))
return guarded
class TestNoSurvivingTestOfTheOldBehaviour:
"""No test in this repository may permit the V1 behaviour.
Not a claim about the code -- the classes above cover that. A claim about the
*tests*: if some suite still encodes "a failure yields silence", then a
future change that restores the blanket handler would be reported as a pass,
and the six V1 sites would come back through the front door wearing a green
test as a disguise.
The rule enforced is precise, and deliberately narrow rather than broad --
a guard that flags legitimate tests gets deleted, and a deleted guard is
worse than none. A test is an offender when **all three** hold:
1. it builds an instance of an ``Exception``-derived class (not a
cancellation, which silence is the correct answer for), **and**
2. it calls a node or generation entry point, **and**
3. that call is not wrapped in ``pytest.raises``.
Silence after an empty input, silence after a cancellation, and a test that
asserts a failure *raises* are all permitted; each is asserted below.
"""
def test_the_scan_actually_reads_the_suite(self) -> None:
sources = _iter_test_sources()
assert len(sources) >= 20, (
f"only {len(sources)} files were scanned; this file would pass "
f"vacuously"
)
def test_no_test_asserts_silence_after_a_generic_failure(self) -> None:
offenders: list[str] = []
for path in _iter_test_sources():
with open(path, "r", encoding="utf-8") as handle:
tree = ast.parse(handle.read(), filename=path)
defined = _module_exception_classes(tree)
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if not node.name.startswith("test"):
continue
failures = _stubbed_failures(node, defined)
if not failures:
continue
guarded = _inside_pytest_raises(node)
for source in _calls_entry_point(node, defined):
if source in guarded:
continue
offenders.append(
f"{os.path.basename(path)}::{node.name}: {source} "
f"(stubs {sorted(failures)})"
)
assert offenders == [], (
"these tests stub a real failure and then assert only that an "
"entry point returned -- which is exactly the V1 "
"'a failure yields silence' shape:\n " + "\n ".join(offenders)
)
@pytest.mark.parametrize(
"source",
[
# The V1 shape, verbatim.
"def test_bad_text_still_produces_output():\n"
" StubPack(error=RuntimeError('boom'))\n"
" result = ChatterboxTTSNode.execute(text='hello')\n"
" assert result is not None\n",
# And the same thing wrapped in a class, which the scan must also see.
"class TestLegacy:\n"
" def test_it_returns(self):\n"
" pack = StubPack(error=ValueError('boom'))\n"
" assert ChatterboxTTSNode.execute(text='hi') is not None\n",
],
ids=["function", "method"],
)
def test_the_detector_catches_the_old_shape(self, source: str) -> None:
"""Positive control.
Without this, "no offenders" is indistinguishable from "the detector
finds nothing at all" -- which is the failure mode that lets a guard
rot into decoration.
"""
tree = ast.parse(source)
defined = _module_exception_classes(tree)
offenders = [
node.name
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and _stubbed_failures(node, defined)
and any(
source_of not in _inside_pytest_raises(node)
for source_of in _calls_entry_point(node, defined)
)
]
assert offenders, (
"the detector missed the V1 shape; the scan above is not evidence "
"of anything"
)
@pytest.mark.parametrize(
"source",
[
# Silence after an empty input: not a failure, so permitted.
"def test_blank_text_is_silence():\n"
" result = ChatterboxTTSNode.execute(text=' ')\n"
" assert result is not None\n",
# Silence after a cancellation: the contract, so permitted.
"def test_cancel_is_silence():\n"
" StubPack(error=FakeInterrupt('cancelled'))\n"
" result = ChatterboxTTSNode.execute(text='hi')\n"
" assert result is not None\n",
# A failure that raises: asserted, so permitted.
"def test_failure_raises():\n"
" StubPack(error=RuntimeError('boom'))\n"
" with pytest.raises(RuntimeError):\n"
" ChatterboxTTSNode.execute(text='hi')\n",
],
ids=["empty-input", "cancellation", "asserted-raise"],
)
def test_the_detector_allows_the_permitted_shapes(self, source: str) -> None:
"""The other half of a positive control: no false positives.
A guard that flags the three legitimate shapes above would be deleted
on sight, and deleting it leaves the package unprotected.
"""
tree = ast.parse(source)
defined = _module_exception_classes(tree)
offenders = [
node.name
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and _stubbed_failures(node, defined)
and any(
source_of not in _inside_pytest_raises(node)
for source_of in _calls_entry_point(node, defined)
)
]
assert offenders == [], f"the detector flagged a legitimate test: {offenders}"
def test_no_shipped_node_wraps_its_body_in_a_broad_handler(self) -> None:
"""The production half: no ``except Exception`` in the node layer.
The interrupt clause is ``except <the interrupt class>``, which is not
``Exception``, so it does not match here. ``modules/generation.py`` is
in the tree because its own ``except Exception`` re-raises after
``logging.exception`` -- a diagnostic, not a swallow -- and the
assertion below is what pins that distinction.
"""
offenders: list[str] = []
for relative in _iter_shipped_sources():
if not relative.startswith("nodes/"):
continue
with open(os.path.join(ROOT_DIR, relative), "r", encoding="utf-8") as handle:
tree = ast.parse(handle.read(), filename=relative)
for node in ast.walk(tree):
if not isinstance(node, ast.ExceptHandler):
continue
caught = node.type
names = (
[caught.id]
if isinstance(caught, ast.Name)
else []
)
if "Exception" in names or "BaseException" in names:
offenders.append(f"{relative}:{node.lineno}")
assert offenders == [], (
f"the node layer catches a broad exception at {offenders}. A "
f"cancelled run is handled by naming the interrupt class; "
f"everything else must propagate."
)
def test_the_interrupt_clause_precedes_the_generic_one(self) -> None:
"""Ordering, structurally, in both places that have both.
Two ``except`` clauses in the right order and two in the wrong order
parse identically; only their sequence differs. A test that merely
checked "an interrupt clause exists" would pass on the broken ordering.
"""
checked = 0
for relative in ("modules/generation.py", "nodes/tts_node.py", "nodes/vc_node.py"):
with open(os.path.join(ROOT_DIR, relative), "r", encoding="utf-8") as handle:
tree = ast.parse(handle.read(), filename=relative)
for node in ast.walk(tree):
if not isinstance(node, ast.Try):
continue
clauses = [
handler
for handler in node.handlers
if handler.type is not None
and not (
isinstance(handler.type, ast.Name)
and handler.type.id in {"Exception", "BaseException"}
)
]
generic = [
index
for index, handler in enumerate(node.handlers)
if isinstance(handler.type, ast.Name)
and handler.type.id == "Exception"
]
if not (clauses and generic):
continue
checked += 1
interrupt_index = min(
index
for index, handler in enumerate(node.handlers)
if handler in clauses
)
assert interrupt_index < min(generic), (
f"{relative}:{node.lineno} catches the generic Exception "
f"before the interrupt class; a cancellation that also "
f"derives from Exception would be reported as a failure "
f"and the run would not cancel"
)
assert checked >= 1, (
"no `try` with both an interrupt clause and an `except Exception` "
"clause was found; the ordering check proved nothing"
)
def test_the_pattern_is_named_so_a_reintroduction_is_recognisable(self) -> None:
"""Keep the description of the old shape in the file that bans it."""
assert OLD_PATTERN_AST_HINT.startswith("ExceptHandler")
assert "silence" in OLD_PATTERN_AST_HINT