Source code for hovel_sdk.testing
from __future__ import annotations
import threading
from collections.abc import Callable
from typing import Any, BinaryIO, Self, cast
from hovel_sdk.framing import encode_message, read_message
from hovel_sdk.module import HovelModule
from hovel_sdk.server import JSONRPCServer
[docs]
class RPCError(RuntimeError):
"""Raised when a test RPC call returns a JSON-RPC error response."""
class _BytePipe:
def __init__(self) -> None:
self._buffer = bytearray()
self._closed = False
self._condition = threading.Condition()
def write(self, data: bytes) -> int:
with self._condition:
if self._closed:
raise ValueError("write to closed pipe")
self._buffer.extend(data)
self._condition.notify_all()
return len(data)
def flush(self) -> None:
return
def close(self) -> None:
with self._condition:
self._closed = True
self._condition.notify_all()
def read(self, size: int = -1) -> bytes:
with self._condition:
if size is None or size < 0:
while not self._closed:
self._condition.wait()
data = bytes(self._buffer)
self._buffer.clear()
return data
while len(self._buffer) < size and not self._closed:
self._condition.wait()
if not self._buffer and self._closed:
return b""
count = min(size, len(self._buffer))
data = bytes(self._buffer[:count])
del self._buffer[:count]
return data
[docs]
class ModuleRPC:
"""Drive a module through the real Content-Length framed JSON-RPC server."""
def __init__(self, module: HovelModule) -> None:
self.notifications: list[dict[str, Any]] = []
self._stdin = _BytePipe()
self._stdout = _BytePipe()
self._next_id = 0
self._closed = False
self._thread = threading.Thread(target=self._serve, args=(module,), daemon=True)
self._thread.start()
[docs]
def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
if self._closed:
raise RuntimeError("module RPC connection is closed")
self._next_id += 1
request_id = self._next_id
request = {
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params or {},
}
self._stdin.write(encode_message(request))
while True:
message = read_message(cast("BinaryIO", self._stdout))
if message is None:
raise RuntimeError(f"module exited before responding to {method}")
if "id" not in message:
self.notifications.append(message)
continue
if message.get("id") != request_id:
raise RuntimeError(f"unexpected response id {message.get('id')!r}, want {request_id!r}")
if "error" in message:
error = message["error"]
if isinstance(error, dict):
raise RPCError(str(error.get("message", error)))
raise RPCError(str(error))
result = message.get("result", {})
if isinstance(result, dict):
return result
return {"value": result}
[docs]
def close(self) -> None:
if self._closed:
return
try:
self.call("shutdown")
finally:
self._closed = True
self._stdin.close()
self._thread.join(timeout=1)
def __enter__(self) -> Self:
return self
def __exit__(self, *_exc: object) -> None:
self.close()
def _serve(self, module: HovelModule) -> None:
try:
JSONRPCServer(module, cast("BinaryIO", self._stdin), cast("BinaryIO", self._stdout)).serve_forever()
finally:
self._stdout.close()
[docs]
def drive_module(module: HovelModule, script: Callable[[ModuleRPC], None]) -> list[dict[str, Any]]:
"""Run a concise RPC script and return notifications emitted during it."""
with ModuleRPC(module) as rpc:
script(rpc)
return list(rpc.notifications)