mail-address-semantics/mas_modes/contract.py
tegwick e91cd01a02 MAS-WP-0010 T01-T05: mode contract v0.1, schemas, callback stub, email as master
mas-modes-v0.1-spec/ (contract, message + gateway-event schemas, transition
table, message fixtures, statute) vendored into mas_modes. EmailMode
implements the master role over the reference ticket service (MasterStore):
handoff, negotiation by email, statuses in the thread, timeouts, resume by
email, principle 10 shared via mas_modes.mailer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 10:57:20 +02:00

103 lines
3.8 KiB
Python

"""Contract building blocks shared by the email side and the stubs."""
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable, Optional
from . import CONTRACT_VERSION, transitions
_T = transitions()
TERMINAL = frozenset(_T["terminal"])
def allowed(old: Optional[str], new: str) -> bool:
"""CONTRACT.md section 4. `old` None means a new handoff."""
return new in _T["transitions"].get(old or "(new)", [])
def message(type_: str, case: Optional[dict] = None, **fields) -> dict:
msg = {"mas_modes_version": CONTRACT_VERSION, "type": type_, "id": uuid.uuid4().hex}
if case is not None:
msg["case"] = case
msg.update({k: v for k, v in fields.items() if v is not None})
return msg
def post(url: str, msg: dict, timeout: float = 10.0) -> tuple[Optional[int], object]:
"""POST a contract message. (None, error) when the peer is unreachable."""
req = urllib.request.Request(url, data=json.dumps(msg).encode(), method="POST",
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode() or "null")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode() or "null")
except (UnicodeDecodeError, json.JSONDecodeError):
return e.code, None
except (OSError, urllib.error.URLError) as e:
return None, str(e)
def refuse(error: str, detail: str = "", status: int = 409, **extra) -> tuple[int, dict]:
return status, {"ok": False, "error": error, "detail": detail, **extra}
class Idempotent:
"""Remember responses by message id (CONTRACT.md section 2)."""
def __init__(self):
self.seen: dict[str, tuple[int, dict]] = {}
self.lock = threading.Lock()
def __call__(self, msg: dict, handler: Callable[[dict], tuple[int, dict]]) -> tuple[int, dict]:
mid = msg.get("id")
with self.lock:
if mid in self.seen:
return self.seen[mid]
out = handler(msg)
if mid and out[0] < 500:
with self.lock:
self.seen[mid] = out
return out
def json_server(routes: dict[str, Callable[[dict], tuple[int, dict]]], host="127.0.0.1",
port=0) -> ThreadingHTTPServer:
"""A loopback JSON-over-HTTP server: POST path -> handler(body) -> (status, body)."""
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
fn = routes.get(self.path)
if fn is None:
return self._send(404, {"ok": False, "error": "not-found"})
try:
data = json.loads(body.decode())
except (UnicodeDecodeError, json.JSONDecodeError):
return self._send(400, {"ok": False, "error": "invalid-json"})
self._send(*fn(data))
def _send(self, status, obj):
out = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
def log_message(self, *a):
pass
return ThreadingHTTPServer((host, port), Handler)
def serve(server: ThreadingHTTPServer) -> str:
"""Run in a daemon thread; returns the base URL."""
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start()
return f"http://{server.server_address[0]}:{server.server_address[1]}"