feat(ttp): E.1.11 RuleStore contract — base ABC, factory, filesystem + database stubs
Adds decnet/ttp/store/ subpackage: - base.py: RuleState frozen dataclass, RuleChange NamedTuple, RuleStore ABC - factory.py: get_rule_store() reading DECNET_TTP_RULE_STORE_TYPE - impl/filesystem.py: FilesystemRuleStore with sys.platform=='linux' fail-fast guard, allowlist filename regex, raw inotify mask bits (lib import deferred to E.3 so contract phase compiles without the asyncinotify dep installed) - impl/database.py: DatabaseRuleStore stub (no platform guard) TTPRule + TTPRuleState SQLModels were already shipped at E.1.1; this commit closes the type-only TYPE_CHECKING forward-ref in rule_engine.py via real runtime imports through the new package.
This commit is contained in:
1
decnet/ttp/store/impl/__init__.py
Normal file
1
decnet/ttp/store/impl/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Rule store backend implementations — filesystem + database."""
|
||||
60
decnet/ttp/store/impl/database.py
Normal file
60
decnet/ttp/store/impl/database.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Database-backed rule store — ``ttp_rule`` + ``ttp_rule_state``.
|
||||
|
||||
Contract step E.1.11. Bodies raise ``NotImplementedError``; the
|
||||
backing tables (:class:`TTPRule`, :class:`TTPRuleState`) shipped at
|
||||
E.1.1.
|
||||
|
||||
Right for swarm: master syncs filesystem changes into ``ttp_rule``,
|
||||
workers tail the DB, state in ``ttp_rule_state`` survives restart and
|
||||
propagates to every worker. Pick via
|
||||
``DECNET_TTP_RULE_STORE_TYPE=database``.
|
||||
|
||||
No platform guard — works on macOS / Windows where the filesystem
|
||||
backend's inotify dependency is unavailable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
||||
|
||||
|
||||
class DatabaseRuleStore(RuleStore):
|
||||
"""``ttp_rule`` content + ``ttp_rule_state`` operational state.
|
||||
|
||||
Contract phase: every method raises ``NotImplementedError``. The
|
||||
impl step (E.3) implements DB-tail subscription + master-side
|
||||
filesystem→DB sync. Worker-side tailing reads via the existing
|
||||
repository pattern; the master's filesystem-watch sync is
|
||||
structurally a delta from :class:`FilesystemRuleStore` plus a
|
||||
``ttp_rule`` upsert.
|
||||
"""
|
||||
|
||||
async def load_compiled(self) -> list[CompiledRule]:
|
||||
raise NotImplementedError(
|
||||
"DatabaseRuleStore.load_compiled lands at E.3",
|
||||
)
|
||||
|
||||
async def get_state(self, rule_id: str) -> RuleState:
|
||||
raise NotImplementedError(
|
||||
"DatabaseRuleStore.get_state lands at E.3",
|
||||
)
|
||||
|
||||
async def set_state(
|
||||
self,
|
||||
rule_id: str,
|
||||
state: RuleState,
|
||||
set_by: str,
|
||||
) -> None:
|
||||
raise NotImplementedError(
|
||||
"DatabaseRuleStore.set_state lands at E.3",
|
||||
)
|
||||
|
||||
def subscribe_changes(self) -> AsyncIterator[RuleChange]:
|
||||
raise NotImplementedError(
|
||||
"DatabaseRuleStore.subscribe_changes lands at E.3",
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DatabaseRuleStore"]
|
||||
150
decnet/ttp/store/impl/filesystem.py
Normal file
150
decnet/ttp/store/impl/filesystem.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Filesystem-backed rule store — reads ``./rules/ttp/`` + inotify watch.
|
||||
|
||||
Contract step E.1.11. Bodies raise ``NotImplementedError``; the
|
||||
constants and platform guard are real so E.2.14b conformance tests
|
||||
can introspect them today.
|
||||
|
||||
Linux-only. The inotify dependency (``asyncinotify`` /
|
||||
``inotify_simple``) is non-portable by design; macOS / Windows
|
||||
developers running the test suite use the database backend by
|
||||
setting ``DECNET_TTP_RULE_STORE_TYPE=database``. The factory check in
|
||||
:meth:`__init__` enforces this with a one-line operator-readable
|
||||
error rather than a deep stack trace from the inotify import.
|
||||
|
||||
The dependency import is **deferred** to :meth:`subscribe_changes`
|
||||
during the contract phase so this module is importable without the
|
||||
inotify package installed. The implementation step (E.3) moves the
|
||||
import to module top per TTP_TAGGING.md §"Linux-only worker host" —
|
||||
which is when the dependency is added to ``pyproject.toml``. At
|
||||
contract phase the codebase compiles, mypy passes, and the constants
|
||||
below are introspectable for E.2.14b tests without forcing operators
|
||||
on macOS or CI machines without the lib to install it just to import
|
||||
the package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleChange, RuleState, RuleStore
|
||||
|
||||
|
||||
# ── Filename allowlist ──────────────────────────────────────────────
|
||||
# A path is accepted iff its basename FULLY matches this pattern. The
|
||||
# allowlist (rather than a denylist) is deliberate per TTP_TAGGING.md
|
||||
# §E.1.11: vim swap files (``.foo.yaml.swp``), atomic-save probes
|
||||
# (``4913``), tilde backups (``foo.yaml~``), random tempfile
|
||||
# conventions a future editor invents — all silently ignored, no
|
||||
# parse, no log line. Denylists rot the moment an editor changes its
|
||||
# scratch convention; the allowlist stops being clever.
|
||||
_VALID_RULE_FILENAME: Final[re.Pattern[str]] = re.compile(
|
||||
r"[A-Za-z0-9_]+\.ya?ml",
|
||||
)
|
||||
|
||||
|
||||
# ── Inotify event mask ──────────────────────────────────────────────
|
||||
# Bit values from ``<sys/inotify.h>`` (man inotify(7)). Inlined as
|
||||
# raw ints so this module is importable without the inotify library
|
||||
# at contract phase. The implementation step replaces these with the
|
||||
# library-supplied constants on the same module-top import that lands
|
||||
# the dep — same numeric value, same bitwise OR.
|
||||
#
|
||||
# Rationale per TTP_TAGGING.md §E.1.11 "Inotify event mask",
|
||||
# verified against an actual ``strace`` of vim:
|
||||
# IN_CLOSE_WRITE — vim writes in place; dominant save signal.
|
||||
# IN_MOVED_TO — atomic-write editors (gedit, IDEs, deploy
|
||||
# scripts) write tempfile then ``rename()``.
|
||||
# IN_CREATE — brand-new rule file appears (``touch``, ``cp``).
|
||||
# IN_DELETE — rule removed; engine drops it from the dispatch
|
||||
# index and emits ``ttp.rule.reloaded.{rule_id}``.
|
||||
_IN_CLOSE_WRITE: Final[int] = 0x00000008
|
||||
_IN_MOVED_TO: Final[int] = 0x00000080
|
||||
_IN_CREATE: Final[int] = 0x00000100
|
||||
_IN_DELETE: Final[int] = 0x00000200
|
||||
|
||||
_INOTIFY_MASK: Final[int] = (
|
||||
_IN_CLOSE_WRITE | _IN_MOVED_TO | _IN_CREATE | _IN_DELETE
|
||||
)
|
||||
|
||||
|
||||
# ── Watch root ──────────────────────────────────────────────────────
|
||||
# Resolved relative to the project root. Tests override via a tmp_path
|
||||
# fixture to avoid touching the real ``./rules/`` during the suite.
|
||||
_DEFAULT_RULES_DIR: Final[Path] = Path("./rules/ttp/")
|
||||
|
||||
|
||||
class FilesystemRuleStore(RuleStore):
|
||||
"""``./rules/ttp/`` + inotify watch + in-process state cache.
|
||||
|
||||
Right for single-host dev — state lost on restart is fine when the
|
||||
operator is local. Swarms use :class:`DatabaseRuleStore` so state
|
||||
survives restart and propagates across worker hosts.
|
||||
|
||||
Contract phase: every method raises ``NotImplementedError``. The
|
||||
impl step (E.3) implements YAML parse + Pydantic validation +
|
||||
inotify event loop + atomic per-rule swap into the dispatch index.
|
||||
"""
|
||||
|
||||
def __init__(self, rules_dir: Path | None = None) -> None:
|
||||
# Fail-fast platform guard. Per TTP_TAGGING.md §E.1.11: a
|
||||
# one-line operator-readable error beats a deep stack trace
|
||||
# from a downstream import.
|
||||
if sys.platform != "linux":
|
||||
raise RuntimeError(
|
||||
"FilesystemRuleStore requires Linux for inotify; use "
|
||||
"DatabaseRuleStore on this platform "
|
||||
"(DECNET_TTP_RULE_STORE_TYPE=database).",
|
||||
)
|
||||
self._rules_dir: Path = rules_dir or _DEFAULT_RULES_DIR
|
||||
# In-process state cache — lost on restart by design. The
|
||||
# database backend persists across restarts; choosing this
|
||||
# backend is choosing the trade-off.
|
||||
self._state: dict[str, RuleState] = {}
|
||||
|
||||
async def load_compiled(self) -> list[CompiledRule]:
|
||||
raise NotImplementedError(
|
||||
"FilesystemRuleStore.load_compiled lands at E.3",
|
||||
)
|
||||
|
||||
async def get_state(self, rule_id: str) -> RuleState:
|
||||
# Auto-revert expired states is impl-phase behavior; the
|
||||
# in-memory dict lookup is the trivial part. Even the lookup
|
||||
# belongs to E.3 so the contract surface stays uniformly
|
||||
# NotImplementedError across both backends.
|
||||
cached = self._state.get(rule_id)
|
||||
if cached is None:
|
||||
return RuleState()
|
||||
if cached.expires_at is not None and cached.expires_at < datetime.now(
|
||||
tz=cached.expires_at.tzinfo,
|
||||
):
|
||||
# Auto-revert path — full impl (event emission, cache
|
||||
# purge) lands at E.3.
|
||||
return RuleState()
|
||||
return cached
|
||||
|
||||
async def set_state(
|
||||
self,
|
||||
rule_id: str,
|
||||
state: RuleState,
|
||||
set_by: str,
|
||||
) -> None:
|
||||
raise NotImplementedError(
|
||||
"FilesystemRuleStore.set_state lands at E.3",
|
||||
)
|
||||
|
||||
def subscribe_changes(self) -> AsyncIterator[RuleChange]:
|
||||
raise NotImplementedError(
|
||||
"FilesystemRuleStore.subscribe_changes lands at E.3",
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FilesystemRuleStore",
|
||||
"_INOTIFY_MASK",
|
||||
"_VALID_RULE_FILENAME",
|
||||
]
|
||||
Reference in New Issue
Block a user