test(ttp): E.2.4 Tagger ABC conformance — hypothesis fuzz over swallowed Exception types
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user