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