mail-address-semantics/mas_modes/stubs/callback.py

162 lines
6.3 KiB
Python
Raw Normal View History

"""Callback mode stub (CONTRACT.md; INTENT.md, Callback stub).
Speaks the mode contract and simulates how a callback can develop. It never
calls anyone. Scenarios are scripted and deterministic: nothing happens
until `step()` (or POST /step) emits the next status. A scenario is chosen
by the request's `details.scenario`, or else by the stub's default.
stub = CallbackStub({"email": key}, default="no-answer-retry")
base = serve(stub.server()) # POST /handoffs (signed), /step (loopback)
Requests are accepted only when signed by email's peer id, and statuses
are signed with the key shared with email (CONTRACT.md section 7).
"""
from __future__ import annotations
import threading
from dataclasses import dataclass, field
from typing import Optional
from .. import _load, message_errors
from ..contract import TERMINAL, Idempotent, json_server, message, post, refuse
WAIT = "wait-for-details" # negotiation: block until handoff.update supplies the needed fields
def _scenarios() -> tuple[dict, tuple]:
"""The scenarios from the bundle (conformance/scenarios.json), as steps of
(status, extra fields) or WAIT."""
doc = _load("conformance/scenarios.json")
needed = ()
out = {}
for name, steps in doc["scenarios"].items():
script = []
for step in steps:
if "wait_for_details" in step:
needed = tuple(step["wait_for_details"])
script.append(WAIT)
else:
extra = {k: v for k, v in step.items() if k != "status"}
script.append((step["status"], extra))
out[name] = script
return out, needed
SCENARIOS, NEEDED = _scenarios()
@dataclass
class Handoff:
request: dict
script: list
details: dict
seq: int = 0
pos: int = 0
status: Optional[str] = None
sent: list = field(default_factory=list)
class CallbackStub:
def __init__(self, keys: dict[str, bytes], default: str = "completed",
peer_id: str = "callback-1", email_peer: str = "email"):
if default not in SCENARIOS:
raise ValueError(f"unknown scenario {default!r}")
self.keys, self.peer_id, self.email_peer = keys, peer_id, email_peer
self.default = default
self.handoffs: dict[str, Handoff] = {}
self.received: list[dict] = []
self.lock = threading.Lock()
self._once = Idempotent()
# -- contract endpoint ---------------------------------------------------------
def receive(self, msg: dict, peer: Optional[str] = None, rest: str = "") -> tuple[int, dict]:
if peer != self.email_peer:
return refuse("wrong-peer", "only email may hand off", 403)
return self._once(msg, self._receive)
def _receive(self, msg):
errors = message_errors(msg)
if errors:
return refuse("schema", "; ".join(errors), 400)
with self.lock:
self.received.append(msg)
t, hid = msg["type"], msg["handoff_id"]
if t == "handoff.request":
name = msg["details"].get("scenario", self.default)
if name not in SCENARIOS:
return refuse("unknown-scenario", name, 400)
script = list(SCENARIOS[name])
details = dict(msg["details"])
if script and script[0] == WAIT and all(details.get(k) for k in NEEDED):
script.pop(0) # nothing to negotiate
self.handoffs[hid] = Handoff(msg, script, details)
return 202, {"ok": True, "handoff_id": hid}
h = self.handoffs.get(hid)
if h is None:
return refuse("unknown-handoff", hid, 404)
if t == "handoff.update":
h.details.update(msg["details"])
return 200, {"ok": True}
if t == "handoff.cancel":
if h.status not in TERMINAL:
h.script[h.pos:] = [("cancelled", {"detail": msg.get("reason", "")})]
return 200, {"ok": True}
return refuse("unsupported", t, 400)
# -- simulation ----------------------------------------------------------------
def step(self, handoff_id: Optional[str] = None) -> Optional[dict]:
"""Emit the next status of one handoff (the oldest one with a step due).
Returns the status message sent, or None if nothing is due."""
with self.lock:
h = self._due(handoff_id)
if h is None:
return None
item = h.script[h.pos]
if item == WAIT:
missing = [k for k in NEEDED if not h.details.get(k)]
if not missing:
h.pos += 2 # past the wait, and the step it releases
item = h.script[h.pos - 1]
elif h.status == "negotiating":
return None # still waiting for handoff.update
else:
item = ("negotiating", {"needs": missing})
else:
h.pos += 1
status, extra = item
h.seq += 1
h.status = status
msg = message("handoff.status", h.request["case"], handoff_id=h.request["handoff_id"],
seq=h.seq, status=status, **extra)
h.sent.append(msg)
url = h.request["status_url"]
post(url, msg, (self.peer_id, self.keys[self.email_peer]))
return msg
def run(self, handoff_id: Optional[str] = None, limit: int = 20) -> list[dict]:
"""Step until nothing more is due (terminal, waiting, or silent)."""
out = []
for _ in range(limit):
m = self.step(handoff_id)
if m is None:
break
out.append(m)
return out
def _due(self, handoff_id):
for hid, h in self.handoffs.items():
if handoff_id not in (None, hid) or h.status in TERMINAL:
continue
if h.pos < len(h.script):
return h
return None
def server(self, host="127.0.0.1", port=0):
step = lambda body, peer, rest: (200, {"ok": True, "sent": self.step(body.get("handoff_id"))}) # noqa: E731
return json_server({"/handoffs": self.receive, "/step": step}, self.keys,
frozenset({"/step"}), host, port)