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`)
**Status:** ✅ done.
- A subclass that doesn't override `tag()` cannot be instantiated.
- `TolerantTagger.tag()` swallows `Exception` from the underlying
`_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
of Tagger, the swallow-Exception / propagate-BaseException boundary
of TolerantTagger, and the closed-by-enumeration KNOWN_SOURCE_KINDS
constant. The full E.2.4 conformance suite (hypothesis fuzz over
arbitrary exception types) lands in a later commit.
E.1.3 contract surface: shape of TaggerEvent, abstractness of
Tagger, the swallow-Exception / propagate-BaseException boundary
of TolerantTagger, and the closed-by-enumeration
KNOWN_SOURCE_KINDS constant.
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
import asyncio
import logging
from typing import Any
import pytest
from hypothesis import HealthCheck, given, settings, strategies as st
from decnet.ttp.base import (
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()
assert ev.source_kind == "command"
assert ev.identity_uuid == "id1"
@@ -45,12 +52,12 @@ def test_tagger_event_is_namedtuple_and_hashable():
assert len(ev) == 7
def test_tagger_is_abstract():
def test_tagger_is_abstract() -> None:
with pytest.raises(TypeError):
Tagger() # type: ignore[abstract]
def test_tagger_subclass_without_tag_is_abstract():
def test_tagger_subclass_without_tag_is_abstract() -> None:
class Half(Tagger):
name = "half"
@@ -58,7 +65,7 @@ def test_tagger_subclass_without_tag_is_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 all(isinstance(k, str) for k in KNOWN_SOURCE_KINDS)
# 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
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):
name = "boom"
HANDLES = frozenset({"command"})
async def _tag_impl(self, event):
async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
raise RuntimeError("synthetic")
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",
[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):
name = "cancel"
HANDLES = frozenset({"command"})
async def _tag_impl(self, event):
async def _tag_impl(self, event: TaggerEvent) -> list[Any]:
raise exc_cls()
with pytest.raises(exc_cls):
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):
name = "empty"
@@ -112,7 +123,78 @@ def test_tolerant_tagger_subclass_must_implement_tag_impl():
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,
# not loudly noisy — the composite skips it entirely.
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]