187 lines
8.5 KiB
Python
187 lines
8.5 KiB
Python
"""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): stdlib + pydantic. ``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 json
|
|
import sys
|
|
from collections.abc import Callable
|
|
from http.client import HTTPException
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Protocol, TextIO, runtime_checkable
|
|
from urllib.error import URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
from pydantic import BaseModel, ConfigDict, model_validator
|
|
|
|
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.
|
|
|
|
The default stream is resolved at CALL time (``None`` → current ``sys.stdout``), so a later
|
|
``redirect_stdout``/capture is honored — an import-time ``sys.stdout`` default would bind the
|
|
original stream object and bypass the redirect."""
|
|
|
|
def __init__(self, stream: TextIO | None = None) -> None:
|
|
self._stream = stream
|
|
|
|
def __call__(self, verdict: Verdict) -> None:
|
|
stream = self._stream if self._stream is not None else sys.stdout
|
|
print(f"[notify] verdict {verdict.id} decision={verdict.decision}", file=stream)
|
|
|
|
|
|
def _verdict_payload(verdict: Verdict) -> dict[str, Any]:
|
|
"""NESTED, byte-deterministic, duck-typed payload — reproduces ``verdicts.verdict_to_dict``'s
|
|
exact shape (verdicts.py:124-139) WITHOUT importing it (a runtime import would pull
|
|
``agent_framework``). Pinned against drift by the ``== verdict_to_dict(v)`` oracle test."""
|
|
f = verdict.proposal_features
|
|
return {
|
|
"id": verdict.id,
|
|
"decision": verdict.decision,
|
|
"rationale": verdict.rationale,
|
|
"proposal_features": {
|
|
"affected_codes": sorted(f.affected_codes),
|
|
"measure_type": f.measure_type,
|
|
"claimed_saving_nok": f.claimed_saving_nok,
|
|
"description": f.description,
|
|
},
|
|
}
|
|
|
|
|
|
class FileNotifier:
|
|
"""Appends one JSONL line per verdict (byte-deterministic: ``sort_keys`` + LF, no wall-clock —
|
|
the ``outbox._dump`` idiom). Parent directories are created as needed."""
|
|
|
|
def __init__(self, path: str) -> None:
|
|
self._path = Path(path)
|
|
|
|
def __call__(self, verdict: Verdict) -> None:
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
line = json.dumps(_verdict_payload(verdict), sort_keys=True) + "\n"
|
|
with self._path.open("a", encoding="utf-8") as handle:
|
|
handle.write(line)
|
|
|
|
|
|
# --- webhook (the ONLY egress point): injectable transport + fail-closed opt-in gate --------------
|
|
|
|
#: The webhook transport seam: ``(url, json-body) -> None``. Injecting a canned implementation in
|
|
#: tests keeps the suite socket-free; the default ``_urllib_post`` is the ONLY path that opens a
|
|
#: socket, and it is reachable ONLY through a gate-passed ``WebhookNotifier``.
|
|
WebhookPost = Callable[[str, str], None]
|
|
|
|
|
|
def _urllib_post(url: str, body: str) -> None:
|
|
"""The stdlib POST — the ONLY socket path in this module (mirrors ``ingest._urllib_get``)."""
|
|
request = Request(
|
|
url,
|
|
data=body.encode("utf-8"),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
with urlopen(request, timeout=10):
|
|
pass
|
|
|
|
|
|
class WebhookNotifier:
|
|
"""POSTs the verdict payload to a webhook URL — the single egress-bearing notifier.
|
|
|
|
**Fail-closed gate (no-silent-egress):** construction refuses (``NotifyRefused``) unless the
|
|
caller passes ``allow_egress=True`` explicitly, per run, in code — the kwarg has no default
|
|
and is NEVER read from config (config cannot self-grant egress; mirrors ingest's
|
|
``allow_network`` gate at ``ingest.py:506``)."""
|
|
|
|
def __init__(self, url: str, *, allow_egress: bool, post: WebhookPost = _urllib_post) -> None:
|
|
if not allow_egress:
|
|
raise NotifyRefused(
|
|
"webhook notifier requires explicit per-run opt-in (allow_egress=True)"
|
|
)
|
|
self._url = url
|
|
self._post = post
|
|
|
|
def __call__(self, verdict: Verdict) -> None:
|
|
try:
|
|
self._post(self._url, json.dumps(_verdict_payload(verdict), sort_keys=True))
|
|
except (URLError, OSError, ValueError, HTTPException) as exc:
|
|
# STRICTER than ingest.py:314: NO exception type may carry the url past this point —
|
|
# a Slack/Teams webhook URL embeds the receiver secret. The tuple covers every
|
|
# URL-carrier _urllib_post can raise (ValueError incl. UnicodeError; HTTPException
|
|
# incl. InvalidURL; OSError incl. URLError/HTTPError/TimeoutError). The chain is
|
|
# severed (`from None`) because __cause__ renders in tracebacks/logging.exception;
|
|
# the exception type name in the message is the diagnostic substitute for the lost
|
|
# chain. Deliberately NOT a bare `except Exception`: the transport is injectable,
|
|
# and a test transport's AssertionError/TypeError (programming errors) must surface.
|
|
raise NotifyError(f"webhook POST failed ({type(exc).__name__})") from None
|
|
|
|
|
|
# --- config + factory (fail-fast, egress never config-grantable) ----------------------------------
|
|
|
|
|
|
class NotifierConfig(BaseModel):
|
|
"""Declarative notifier choice. ``type`` membership is enforced by ``build_notifier`` (unknown
|
|
type → ``ValueError``); the validator pins the per-type required fields. Deliberately NO
|
|
``allow_egress`` field — egress opt-in is a code-level factory kwarg, so config alone can
|
|
never grant it (mirrors ingest's ``allow_network`` discipline)."""
|
|
|
|
# the url field is secret-bearing (webhook URLs embed the receiver secret), so
|
|
# validation errors must not echo input (pydantic would render input_value='…secret')
|
|
model_config = ConfigDict(hide_input_in_errors=True)
|
|
|
|
type: str
|
|
url: str | None = None
|
|
path: str | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _required_fields_by_type(self) -> NotifierConfig:
|
|
if self.type == "webhook" and not self.url:
|
|
raise ValueError("webhook notifier config requires a non-empty url")
|
|
if self.type == "webhook" and self.url and not self.url.startswith(("http://", "https://")):
|
|
raise ValueError("webhook notifier url must start with http:// or https://")
|
|
if self.type == "file" and not self.path:
|
|
raise ValueError("file notifier config requires a non-empty path")
|
|
return self
|
|
|
|
|
|
def build_notifier(config: NotifierConfig, *, allow_egress: bool = False) -> Notifier:
|
|
"""Type-dispatch factory. The webhook branch threads ``allow_egress`` into
|
|
``WebhookNotifier`` — with the default ``False`` the Step-3 gate fires (``NotifyRefused``),
|
|
so a config file alone still cannot enable egress."""
|
|
if config.type == "console":
|
|
return ConsoleNotifier()
|
|
if config.type == "file":
|
|
assert config.path is not None # guaranteed by _required_fields_by_type
|
|
return FileNotifier(config.path)
|
|
if config.type == "webhook":
|
|
assert config.url is not None # guaranteed by _required_fields_by_type
|
|
return WebhookNotifier(config.url, allow_egress=allow_egress)
|
|
raise ValueError(f"unknown notifier type: {config.type!r}")
|