feat(s52): Notifier protocol + console notifier + MAF-free registration
This commit is contained in:
parent
4ecd571961
commit
d5b583349a
4 changed files with 196 additions and 1 deletions
48
src/portfolio_optimiser/notify.py
Normal file
48
src/portfolio_optimiser/notify.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""S5.2 — Varsling (notification): B11 notify-stub → leverbare Notifiers (roadmap E, målbilde §3).
|
||||
|
||||
``run_project`` takes ``notify: Callable[[Verdict], None] | None`` and calls ``notify(verdict)``
|
||||
after the verdict is captured — this module supplies the deliverable implementations behind that
|
||||
seam (the seam itself stays untouched). ``Notifier`` is the declared contract: a
|
||||
``__call__``-shaped structural superset of the seam's ``Callable``, so any plain callable still
|
||||
satisfies it.
|
||||
|
||||
**MAF-free** (D7-portable): pure stdlib. ``Verdict`` is imported ONLY under ``TYPE_CHECKING`` and
|
||||
every runtime access is duck-typed — so importing this module never pulls in ``agent_framework``.
|
||||
Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` (direct-import guard
|
||||
``test_okf_is_maf_free``) and probed transitively by
|
||||
``tests/test_notify_loadbearing.py::test_notify_transitive_import_graph_is_maf_free``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Protocol, TextIO, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
|
||||
from portfolio_optimiser.verdicts import Verdict
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Notifier(Protocol):
|
||||
"""The declared notify contract (B11): structurally a superset of the run-path seam
|
||||
``Callable[[Verdict], None]`` — ``isinstance(fn, Notifier)`` holds for any plain callable."""
|
||||
|
||||
def __call__(self, verdict: Verdict) -> None: ...
|
||||
|
||||
|
||||
class NotifyError(RuntimeError):
|
||||
"""A notifier failed to deliver (e.g. webhook transport failure)."""
|
||||
|
||||
|
||||
class NotifyRefused(Exception):
|
||||
"""Fail-closed egress-gate refusal (mirrors ``verdicts.PromotionRefused``)."""
|
||||
|
||||
|
||||
class ConsoleNotifier:
|
||||
"""One-line verdict summary to a text stream (default stdout). Never receives a URL."""
|
||||
|
||||
def __init__(self, stream: TextIO = sys.stdout) -> None:
|
||||
self._stream = stream
|
||||
|
||||
def __call__(self, verdict: Verdict) -> None:
|
||||
print(f"[notify] verdict {verdict.id} decision={verdict.decision}", file=self._stream)
|
||||
42
tests/test_notify.py
Normal file
42
tests/test_notify.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""S5.2 — Varsling: unit/contract tests for ``notify.py`` (Notifier protocol + notifiers).
|
||||
|
||||
Detach-seam (RED-when-detached) twins live in ``tests/test_notify_loadbearing.py``. Tests may
|
||||
import MAF-tainted modules freely (``verdicts``) — only ``notify.py`` itself must stay MAF-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from portfolio_optimiser.notify import ConsoleNotifier, Notifier
|
||||
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict
|
||||
|
||||
|
||||
def _verdict() -> Verdict:
|
||||
return capture_verdict(
|
||||
ProposalFeatures(
|
||||
affected_codes=frozenset({"05.2", "03.1"}),
|
||||
measure_type="scope_reduction",
|
||||
claimed_saving_nok=200_000.0,
|
||||
description="LED retrofit",
|
||||
),
|
||||
"approved",
|
||||
"feasible within range",
|
||||
)
|
||||
|
||||
|
||||
def test_plain_callable_satisfies_notifier() -> None:
|
||||
"""The seam promise: any plain callable satisfies the runtime-checkable ``Notifier`` protocol,
|
||||
so ``run_project(notify=lambda v: ...)`` stays valid — a structural superset of
|
||||
``Callable[[Verdict], None]``, not a new ABC (needs ``@runtime_checkable``)."""
|
||||
assert isinstance(lambda v: None, Notifier)
|
||||
|
||||
|
||||
def test_console_emits_id_and_decision() -> None:
|
||||
"""ConsoleNotifier writes the verdict id + decision to the injected stream (never a URL)."""
|
||||
stream = io.StringIO()
|
||||
verdict = _verdict()
|
||||
ConsoleNotifier(stream=stream)(verdict)
|
||||
out = stream.getvalue()
|
||||
assert verdict.id in out
|
||||
assert verdict.decision in out
|
||||
105
tests/test_notify_loadbearing.py
Normal file
105
tests/test_notify_loadbearing.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""S5.2 — Varsling: load-bearing detach seams for ``notify.py``. Each test goes RED when the
|
||||
guarded mechanism is removed; a bare happy-path pass would not catch the regression.
|
||||
|
||||
Seams pinned here:
|
||||
- **MAF-free registration** — the direct-import guard (``test_okf_is_maf_free``) actually scans
|
||||
``notify.py`` (otherwise the MAF-free claim is green-but-dead);
|
||||
- **transitive import-graph MAF-freedom** — a TYPE_CHECKING-aware static BFS over notify's
|
||||
first-party RUNTIME import edges (model: the S51 hitl probe, forbidden roots
|
||||
``{agent_framework, mcp}`` ONLY — notify legitimately imports ``urllib`` for the webhook seam).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
_SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser"
|
||||
_MAF_ROOTS = {"agent_framework", "mcp"}
|
||||
|
||||
|
||||
def test_notify_registered_maf_free() -> None:
|
||||
"""Meta: notify.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free``
|
||||
actually scans it — otherwise the MAF-free claim would be green-but-dead (never checked)."""
|
||||
from tests.test_okf import _MAF_FREE_MODULES
|
||||
|
||||
assert "notify.py" in _MAF_FREE_MODULES
|
||||
|
||||
|
||||
def _runtime_import_nodes(tree: ast.Module) -> list[ast.Import | ast.ImportFrom]:
|
||||
"""Every ``Import``/``ImportFrom`` node OUTSIDE an ``if TYPE_CHECKING:`` body. notify's
|
||||
``Verdict`` import is type-only (TYPE_CHECKING) and must NOT count as a runtime edge —
|
||||
the probe walks the runtime graph, exactly what D7 portability is about."""
|
||||
skip: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.If):
|
||||
test = node.test
|
||||
if (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (
|
||||
isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
|
||||
):
|
||||
for child in node.body:
|
||||
for sub in ast.walk(child):
|
||||
skip.add(id(sub))
|
||||
return [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)) and id(node) not in skip
|
||||
]
|
||||
|
||||
|
||||
def _first_party_submodule_edges(tree: ast.Module) -> set[str]:
|
||||
"""``portfolio_optimiser.<name>`` RUNTIME submodule import edges — modeled on
|
||||
``test_hitl_loadbearing._first_party_submodule_edges`` (same package-top/``__init__``
|
||||
discount), restricted to non-TYPE_CHECKING nodes via ``_runtime_import_nodes``."""
|
||||
edges: set[str] = set()
|
||||
for node in _runtime_import_nodes(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
parts = (node.module or "").split(".")
|
||||
if len(parts) >= 2 and parts[0] == "portfolio_optimiser" and parts[1] != "__init__":
|
||||
edges.add(parts[1])
|
||||
else:
|
||||
for alias in node.names:
|
||||
parts = alias.name.split(".")
|
||||
if len(parts) >= 2 and parts[0] == "portfolio_optimiser" and parts[1] != "__init__":
|
||||
edges.add(parts[1])
|
||||
return edges
|
||||
|
||||
|
||||
def _imports_maf(tree: ast.Module) -> bool:
|
||||
for node in _runtime_import_nodes(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
if any(a.name.split(".")[0] in _MAF_ROOTS for a in node.names):
|
||||
return True
|
||||
elif (node.module or "").split(".")[0] in _MAF_ROOTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_notify_transitive_import_graph_is_maf_free() -> None:
|
||||
"""LOAD-BEARING: no module reachable from ``notify.py``'s OWN first-party RUNTIME import edges
|
||||
imports ``agent_framework``/``mcp``. Static AST BFS (never traversing ``__init__.py``;
|
||||
TYPE_CHECKING edges discounted — the ``Verdict`` annotation is type-only by design). Detach
|
||||
point (documented): promote the ``verdicts`` import to runtime (e.g. ``from
|
||||
portfolio_optimiser.verdicts import verdict_to_dict``) → the walk reaches ``verdicts.py``
|
||||
(``agent_framework`` at :29) → RED. A clean notify → GREEN."""
|
||||
seen: set[str] = set()
|
||||
queue = list(
|
||||
_first_party_submodule_edges(ast.parse((_SRC_DIR / "notify.py").read_text("utf-8")))
|
||||
)
|
||||
maf_bearing: list[str] = []
|
||||
while queue:
|
||||
mod = queue.pop()
|
||||
if mod in seen:
|
||||
continue
|
||||
seen.add(mod)
|
||||
mod_file = _SRC_DIR / f"{mod}.py"
|
||||
if not mod_file.is_file():
|
||||
continue
|
||||
tree = ast.parse(mod_file.read_text("utf-8"))
|
||||
if _imports_maf(tree):
|
||||
maf_bearing.append(mod)
|
||||
queue.extend(_first_party_submodule_edges(tree) - seen)
|
||||
assert maf_bearing == [], (
|
||||
f"notify's transitive first-party import graph reaches MAF-bearing module(s): "
|
||||
f"{sorted(maf_bearing)}"
|
||||
)
|
||||
|
|
@ -18,7 +18,7 @@ from portfolio_optimiser import okf
|
|||
|
||||
# Framework-neutral, D7-portable modules that must never import MAF/mcp (C2:
|
||||
# the guard previously scanned only okf.py; dimension.py is now covered too).
|
||||
_MAF_FREE_MODULES = ["okf.py", "dimension.py", "outbox.py", "costsim.py", "hitl.py"]
|
||||
_MAF_FREE_MODULES = ["okf.py", "dimension.py", "outbox.py", "costsim.py", "hitl.py", "notify.py"]
|
||||
|
||||
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue