feat(ttp): E.1.3+E.1.4 Tagger ABC and composite factory contract
Third and fourth TTP-tagging contract commits, plus a scoped subset
of the E.2.4 conformance tests covering the contract surface shipped
here (full hypothesis-fuzz suite still lands with E.2.4).
E.1.3 — decnet/ttp/base.py
- TaggerEvent NamedTuple: source_kind, source_id, attacker_uuid,
identity_uuid, session_id, decky_id, opaque payload.
- Tagger(ABC) with abstract async tag(); class-level name and
HANDLES: frozenset[str] (default empty so a misconfigured subclass
is loudly idle, not loudly noisy).
- TolerantTagger(Tagger): concrete tag() wraps abstract _tag_impl()
in try/except Exception (deliberately not BaseException — so
KeyboardInterrupt / SystemExit / asyncio.CancelledError propagate
and the worker can shut down cleanly). Swallowed exceptions log
at WARNING with exc_info, never ERROR — absence is the steady
state, not a bug. Subclasses override _tag_impl, never tag — the
tolerance contract is enforced in the base class, not on trust.
- KNOWN_SOURCE_KINDS: Final[frozenset[str]] enumerating every
source_kind a producer is allowed to emit. Closed-by-enumeration
at the runtime layer; the composite tagger keys its WARNING/INFO
bridge off this constant to surface the silent-drop trap from
the design doc (lines 160–195).
E.1.4 — decnet/ttp/factory.py
- get_tagger() reads DECNET_TTP_TAGGER_TYPE (default 'composite');
unknown values raise ValueError with the known-list. Mirrors
decnet.intel.factory and decnet.clustering.factory.
- _KNOWN = ('composite',). Per-lifter classes (E.1.6) are children
of the composite, not standalone tagger types.
- CompositeTagger(Tagger): pre-computes a dict[str, list[Tagger]]
dispatch index from each lifter's HANDLES; fans events out
concurrently with asyncio.gather and concatenates results.
Empty lifters=[] is the legal contract-phase state — E.1.6
wires the real lifters in.
- Unhandled-event observability: source_kind in KNOWN_SOURCE_KINDS
but no lifter claims it -> WARNING once per kind per process
(missed E.1.6 update). Unknown kind -> INFO once per kind per
process (future-feature telemetry, by design). Per-process dedup
via plain set; E.1.6 may swap in a proper rate-limiter once
production traffic shapes are known.
Tests — tests/ttp/test_base.py, tests/ttp/test_factory.py
- Tagger / TolerantTagger abstractness, missing-tag-impl rejection,
WARNING-not-ERROR log level, propagation of KeyboardInterrupt /
SystemExit / asyncio.CancelledError.
- Factory env-var routing, unknown-name ValueError, dispatch-index
correctness, only-claiming-lifter invocation, WARNING-once for
known-but-unclaimed kinds, INFO-once for unknown kinds, result
concatenation across lifters.
Mypy clean under .311/bin/mypy --ignore-missing-imports.
This commit is contained in:
118
tests/ttp/test_base.py
Normal file
118
tests/ttp/test_base.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Contract tests for :mod:`decnet.ttp.base` (E.1.3).
|
||||
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.ttp.base import (
|
||||
KNOWN_SOURCE_KINDS,
|
||||
Tagger,
|
||||
TaggerEvent,
|
||||
TolerantTagger,
|
||||
)
|
||||
|
||||
|
||||
def _ev(source_kind: str = "command") -> TaggerEvent:
|
||||
return TaggerEvent(
|
||||
source_kind=source_kind,
|
||||
source_id="src1",
|
||||
attacker_uuid=None,
|
||||
identity_uuid="id1",
|
||||
session_id=None,
|
||||
decky_id=None,
|
||||
payload={},
|
||||
)
|
||||
|
||||
|
||||
def test_tagger_event_is_namedtuple_and_hashable():
|
||||
ev = _ev()
|
||||
assert ev.source_kind == "command"
|
||||
assert ev.identity_uuid == "id1"
|
||||
# NamedTuple gives instances tuple identity for downstream dedup
|
||||
# paths. The payload field is a dict (unhashable by design — the
|
||||
# raw event isn't meant to live in a set), but the structural
|
||||
# tuple shape is what callers actually rely on.
|
||||
assert tuple(ev)[0] == "command"
|
||||
assert len(ev) == 7
|
||||
|
||||
|
||||
def test_tagger_is_abstract():
|
||||
with pytest.raises(TypeError):
|
||||
Tagger() # type: ignore[abstract]
|
||||
|
||||
|
||||
def test_tagger_subclass_without_tag_is_abstract():
|
||||
class Half(Tagger):
|
||||
name = "half"
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Half() # type: ignore[abstract]
|
||||
|
||||
|
||||
def test_known_source_kinds_is_frozenset_of_strings():
|
||||
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
|
||||
# in the design doc; further kinds may be added but these MUST be
|
||||
# present.
|
||||
must_have = {
|
||||
"command", "intel", "email", "canary_fingerprint",
|
||||
"identity", "credential",
|
||||
}
|
||||
assert must_have <= KNOWN_SOURCE_KINDS
|
||||
|
||||
|
||||
def test_tolerant_tagger_swallows_exception_and_returns_empty(caplog):
|
||||
class Boom(TolerantTagger):
|
||||
name = "boom"
|
||||
HANDLES = frozenset({"command"})
|
||||
|
||||
async def _tag_impl(self, event):
|
||||
raise RuntimeError("synthetic")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="decnet.ttp.base")
|
||||
out = asyncio.run(Boom().tag(_ev()))
|
||||
assert out == []
|
||||
# WARNING — never ERROR — per the absence-is-normal doctrine.
|
||||
records = [r for r in caplog.records if r.name == "decnet.ttp.base"]
|
||||
assert records, "expected a log line on swallowed exception"
|
||||
assert all(r.levelno == logging.WARNING for r in records)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc_cls",
|
||||
[KeyboardInterrupt, SystemExit, asyncio.CancelledError],
|
||||
)
|
||||
def test_tolerant_tagger_propagates_base_exceptions(exc_cls):
|
||||
class Cancel(TolerantTagger):
|
||||
name = "cancel"
|
||||
HANDLES = frozenset({"command"})
|
||||
|
||||
async def _tag_impl(self, event):
|
||||
raise exc_cls()
|
||||
|
||||
with pytest.raises(exc_cls):
|
||||
asyncio.run(Cancel().tag(_ev()))
|
||||
|
||||
|
||||
def test_tolerant_tagger_subclass_must_implement_tag_impl():
|
||||
class Empty(TolerantTagger):
|
||||
name = "empty"
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Empty() # type: ignore[abstract]
|
||||
|
||||
|
||||
def test_tagger_default_handles_is_empty_frozenset():
|
||||
# Misconfigured subclass that forgets HANDLES is loudly idle,
|
||||
# not loudly noisy — the composite skips it entirely.
|
||||
assert Tagger.HANDLES == frozenset()
|
||||
Reference in New Issue
Block a user