Files
DECNET/decnet/ttp/store/impl/filesystem.py
anti bcd1f14cd3 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.
2026-05-01 07:25:09 -04:00

151 lines
6.3 KiB
Python

"""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",
]