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`)
|
**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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user