From 5accf8f1b128854d05ca57c362cd27165c78cdc1 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 06:54:29 -0400 Subject: [PATCH] =?UTF-8?q?test(ttp):=20E.2.4=20Tagger=20ABC=20conformance?= =?UTF-8?q?=20=E2=80=94=20hypothesis=20fuzz=20over=20swallowed=20Exception?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- development/TTP_TAGGING.md | 2 + tests/ttp/test_base.py | 114 +++++++++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 671f9897..084db35e 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -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 diff --git a/tests/ttp/test_base.py b/tests/ttp/test_base.py index e9b44fe8..2d0445e7 100644 --- a/tests/ttp/test_base.py +++ b/tests/ttp/test_base.py @@ -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]