test(ttp): E.2.4 Tagger ABC conformance — hypothesis fuzz over swallowed Exception types

This commit is contained in:
2026-05-01 06:54:29 -04:00
parent cce84f23dc
commit 5accf8f1b1
2 changed files with 100 additions and 16 deletions

View File

@@ -2548,6 +2548,8 @@ lands; PII rule §6 type assertion is GREEN today).
**E.2.4 — Tagger ABC conformance** (`tests/ttp/test_base.py`) **E.2.4 — Tagger ABC conformance** (`tests/ttp/test_base.py`)
**Status:** ✅ done.
- A subclass that doesn't override `tag()` cannot be instantiated. - A subclass that doesn't override `tag()` cannot be instantiated.
- `TolerantTagger.tag()` swallows `Exception` from the underlying - `TolerantTagger.tag()` swallows `Exception` from the underlying
`_tag_impl()` and returns `[]`. Hypothesis fuzz with raised `_tag_impl()` and returns `[]`. Hypothesis fuzz with raised

View File

@@ -1,17 +1,24 @@
"""Contract tests for :mod:`decnet.ttp.base` (E.1.3). """Contract tests for :mod:`decnet.ttp.base` (E.1.3 + E.2.4).
Scoped to the contract surface: shape of TaggerEvent, abstractness E.1.3 contract surface: shape of TaggerEvent, abstractness of
of Tagger, the swallow-Exception / propagate-BaseException boundary Tagger, the swallow-Exception / propagate-BaseException boundary
of TolerantTagger, and the closed-by-enumeration KNOWN_SOURCE_KINDS of TolerantTagger, and the closed-by-enumeration
constant. The full E.2.4 conformance suite (hypothesis fuzz over KNOWN_SOURCE_KINDS constant.
arbitrary exception types) lands in a later commit.
E.2.4 conformance (this commit): hypothesis fuzz over a curated
set of Exception subclasses → all swallowed and converted to ``[]``
at WARNING level (never ERROR). The propagate-list
(``KeyboardInterrupt`` / ``SystemExit`` /
``asyncio.CancelledError``) stays separate.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from typing import Any
import pytest import pytest
from hypothesis import HealthCheck, given, settings, strategies as st
from decnet.ttp.base import ( from decnet.ttp.base import (
KNOWN_SOURCE_KINDS, KNOWN_SOURCE_KINDS,
@@ -33,7 +40,7 @@ def _ev(source_kind: str = "command") -> TaggerEvent:
) )
def test_tagger_event_is_namedtuple_and_hashable(): def test_tagger_event_is_namedtuple_and_hashable() -> None:
ev = _ev() ev = _ev()
assert ev.source_kind == "command" assert ev.source_kind == "command"
assert ev.identity_uuid == "id1" assert ev.identity_uuid == "id1"
@@ -45,12 +52,12 @@ def test_tagger_event_is_namedtuple_and_hashable():
assert len(ev) == 7 assert len(ev) == 7
def test_tagger_is_abstract(): def test_tagger_is_abstract() -> None:
with pytest.raises(TypeError): with pytest.raises(TypeError):
Tagger() # type: ignore[abstract] Tagger() # type: ignore[abstract]
def test_tagger_subclass_without_tag_is_abstract(): def test_tagger_subclass_without_tag_is_abstract() -> None:
class Half(Tagger): class Half(Tagger):
name = "half" name = "half"
@@ -58,7 +65,7 @@ def test_tagger_subclass_without_tag_is_abstract():
Half() # type: ignore[abstract] Half() # type: ignore[abstract]
def test_known_source_kinds_is_frozenset_of_strings(): def test_known_source_kinds_is_frozenset_of_strings() -> None:
assert isinstance(KNOWN_SOURCE_KINDS, frozenset) assert isinstance(KNOWN_SOURCE_KINDS, frozenset)
assert all(isinstance(k, str) for k in KNOWN_SOURCE_KINDS) assert all(isinstance(k, str) for k in KNOWN_SOURCE_KINDS)
# The contract requires at least the lifter-aligned kinds enumerated # The contract requires at least the lifter-aligned kinds enumerated
@@ -71,12 +78,14 @@ def test_known_source_kinds_is_frozenset_of_strings():
assert must_have <= KNOWN_SOURCE_KINDS assert must_have <= KNOWN_SOURCE_KINDS
def test_tolerant_tagger_swallows_exception_and_returns_empty(caplog): def test_tolerant_tagger_swallows_exception_and_returns_empty(
caplog: pytest.LogCaptureFixture,
) -> None:
class Boom(TolerantTagger): class Boom(TolerantTagger):
name = "boom" name = "boom"
HANDLES = frozenset({"command"}) HANDLES = frozenset({"command"})
async def _tag_impl(self, event): async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
raise RuntimeError("synthetic") raise RuntimeError("synthetic")
caplog.set_level(logging.WARNING, logger="decnet.ttp.base") caplog.set_level(logging.WARNING, logger="decnet.ttp.base")
@@ -92,19 +101,21 @@ def test_tolerant_tagger_swallows_exception_and_returns_empty(caplog):
"exc_cls", "exc_cls",
[KeyboardInterrupt, SystemExit, asyncio.CancelledError], [KeyboardInterrupt, SystemExit, asyncio.CancelledError],
) )
def test_tolerant_tagger_propagates_base_exceptions(exc_cls): def test_tolerant_tagger_propagates_base_exceptions(
exc_cls: type[BaseException],
) -> None:
class Cancel(TolerantTagger): class Cancel(TolerantTagger):
name = "cancel" name = "cancel"
HANDLES = frozenset({"command"}) HANDLES = frozenset({"command"})
async def _tag_impl(self, event): async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
raise exc_cls() raise exc_cls()
with pytest.raises(exc_cls): with pytest.raises(exc_cls):
asyncio.run(Cancel().tag(_ev())) asyncio.run(Cancel().tag(_ev()))
def test_tolerant_tagger_subclass_must_implement_tag_impl(): def test_tolerant_tagger_subclass_must_implement_tag_impl() -> None:
class Empty(TolerantTagger): class Empty(TolerantTagger):
name = "empty" name = "empty"
@@ -112,7 +123,78 @@ def test_tolerant_tagger_subclass_must_implement_tag_impl():
Empty() # type: ignore[abstract] Empty() # type: ignore[abstract]
def test_tagger_default_handles_is_empty_frozenset(): def test_tagger_default_handles_is_empty_frozenset() -> None:
# Misconfigured subclass that forgets HANDLES is loudly idle, # Misconfigured subclass that forgets HANDLES is loudly idle,
# not loudly noisy — the composite skips it entirely. # not loudly noisy — the composite skips it entirely.
assert Tagger.HANDLES == frozenset() assert Tagger.HANDLES == frozenset()
# ─── E.2.4 — Hypothesis fuzz over the swallow-Exception boundary ─────────────
# Curated list of plausible runtime failure modes a lifter could hit
# when its sibling-worker join is absent or malformed. Spec calls for
# ``st.sampled_from`` over a curated list — no fully random class
# synthesis (which could pick BaseException subclasses we explicitly
# do NOT want swallowed).
_SWALLOWED_EXCS: tuple[type[Exception], ...] = (
ValueError,
RuntimeError,
KeyError,
TypeError,
AttributeError,
LookupError,
OSError,
asyncio.TimeoutError,
ZeroDivisionError,
StopIteration,
)
@given(exc_cls=st.sampled_from(_SWALLOWED_EXCS))
@settings(
max_examples=50,
deadline=None,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_tolerant_tagger_fuzzed_exceptions_swallowed(
exc_cls: type[Exception],
caplog: pytest.LogCaptureFixture,
) -> None:
class Boom(TolerantTagger):
name = "boom"
HANDLES = frozenset({"command"})
async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
raise exc_cls("synthetic")
caplog.clear()
caplog.set_level(logging.DEBUG, logger="decnet.ttp.base")
out = asyncio.run(Boom().tag(_ev()))
assert out == []
records = [r for r in caplog.records if r.name == "decnet.ttp.base"]
assert records, f"expected a WARNING for {exc_cls.__name__}"
# Absence-is-normal: every swallowed exception logs at WARNING,
# never ERROR. A future change that flips to ERROR — paging the
# operator on every empty join — trips this assert.
assert all(r.levelno == logging.WARNING for r in records)
assert not any(r.levelno >= logging.ERROR for r in records)
def test_tolerant_tagger_no_error_records_on_swallow(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Sibling property to the fuzz test: pin the no-ERROR invariant
independent of Hypothesis sampling, so the contract is visible
in plain pytest output."""
class Boom(TolerantTagger):
name = "boom"
HANDLES = frozenset({"command"})
async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
raise KeyError("no such join")
caplog.set_level(logging.DEBUG, logger="decnet.ttp.base")
asyncio.run(Boom().tag(_ev()))
base_records = [r for r in caplog.records if r.name == "decnet.ttp.base"]
assert base_records
assert not [r for r in base_records if r.levelno >= logging.ERROR]