"""Standalone interactive HTTP Human Agent. Run this as a process on the operator's machine to expose a single ``POST /act`` endpoint that the Texas Hold'em service can call when it is that operator's turn to act: python -m texas_holdem.human_client --host 127.0.0.1 --port 9001 Then create a game on the server with this player spec:: { "id": "alice", "name": "Alice", "agent": { "type": "http", "endpoint": "http://127.0.0.1:9001/act", "timeout_seconds": 600 } } Every time the server posts an observation, this client renders it on the local terminal and blocks on stdin until the human chooses a legal action, then returns ``{"action": "...", "amount": N}`` as JSON. Design notes: - The HTTP layer reuses :mod:`texas_holdem.human_io` so rendering and menu validation stay consistent with the in-process :class:`HumanAgent`. - A module-level :class:`threading.Lock` serialises terminal access. This is necessary because the (rare) case of multiple overlapping requests from the server must not interleave prompts on the same TTY. """ from __future__ import annotations import argparse import json import sys from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Lock from typing import IO, Any from texas_holdem.human_io import prompt_action, render_observation class HumanClientConsole: """Encapsulates terminal IO with a lock to serialise prompts. Wrapping the streams in a tiny class keeps stream injection (handy for tests) and concurrency control in one place, instead of leaking through free functions. """ def __init__( self, input_stream: IO[str] | None = None, output_stream: IO[str] | None = None, ) -> None: self._input = input_stream if input_stream is not None else sys.stdin self._output = output_stream if output_stream is not None else sys.stdout # The lock guards both the printed observation block and the prompt # loop so two concurrent /act calls would never interleave on the # same TTY. self._lock = Lock() def decide(self, observation: dict[str, Any]) -> dict[str, Any]: """Render an observation and return the operator's action dict.""" with self._lock: self._write(render_observation(observation)) return prompt_action( list(observation.get("legal_actions") or []), self._read_line, self._write, ) def _write(self, text: str) -> None: self._output.write(text) self._output.flush() def _read_line(self, prompt: str) -> str: self._write(prompt) line = self._input.readline() if line == "": raise EOFError("input stream closed while waiting for human action") return line.rstrip("\n") class HumanRequestHandler(BaseHTTPRequestHandler): """HTTP entry point for the standalone human agent. Only ``POST /act`` is meaningful; ``GET /health`` is provided so deploys can quickly probe whether the client is alive before hooking it up. """ server_version = "TexasHoldemHumanClient/0.1" # Injected by :func:`create_server` on the underlying server instance so # every handler shares the same terminal console. console: HumanClientConsole # type: ignore[assignment] def do_GET(self) -> None: if self.path == "/health": self._json({"ok": True}) return self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) def do_POST(self) -> None: if self.path != "/act": self._json({"error": "not found"}, HTTPStatus.NOT_FOUND) return try: payload = self._read_json() except ValueError as exc: self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return try: action = self.console.decide(payload) except EOFError as exc: # The operator closed stdin (Ctrl-D); surface as 503 so the # server can fall back to its default coercion (fold). self._json({"error": str(exc)}, HTTPStatus.SERVICE_UNAVAILABLE) return except Exception as exc: # pragma: no cover - defensive guard self._json({"error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) return self._json(action) # Silence the default access log so it does not interleave with prompts. def log_message(self, format: str, *args: Any) -> None: # noqa: A002 return def _read_json(self) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) if length <= 0: raise ValueError("request body is required") try: payload = json.loads(self.rfile.read(length).decode("utf-8")) except json.JSONDecodeError as exc: raise ValueError("request body must be valid JSON") from exc if not isinstance(payload, dict): raise ValueError("request body must be a JSON object") return payload def _json( self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK, ) -> None: body = json.dumps(payload, ensure_ascii=True).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def create_server( host: str, port: int, console: HumanClientConsole | None = None, ) -> ThreadingHTTPServer: """Build a server with a shared :class:`HumanClientConsole`. Exposed as a function so tests (or callers wiring custom IO streams) can construct the server without touching ``main``. """ server = ThreadingHTTPServer((host, port), HumanRequestHandler) HumanRequestHandler.console = console or HumanClientConsole() return server def main() -> None: parser = argparse.ArgumentParser( description="Run an interactive HTTP Human Agent that exposes POST /act.", ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", default=9001, type=int) args = parser.parse_args() server = create_server(args.host, args.port) print( f"Human HTTP agent listening on http://{args.host}:{args.port}/act\n" "Use this URL as the 'endpoint' field of a 'http' agent spec.", file=sys.stderr, flush=True, ) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() if __name__ == "__main__": main()