Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"), README, CLI strings, and branch (coven → hack-house). Gitea repo renamed cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest + golden-vector test still green, binary is now `hack-house`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
7.3 KiB
Python
238 lines
7.3 KiB
Python
import asyncio
|
|
import typing
|
|
from functools import partial
|
|
from random import randint
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import httpx
|
|
from sanic import Sanic
|
|
from sanic.application.state import ApplicationServerInfo
|
|
from sanic.log import logger
|
|
from sanic.request import Request
|
|
|
|
from sanic_testing.websocket import websocket_proxy
|
|
|
|
from .testing import HOST, PORT, TestingResponse
|
|
|
|
|
|
class ReusableClient:
|
|
def __init__(
|
|
self,
|
|
app: Sanic,
|
|
host=HOST,
|
|
port=PORT,
|
|
loop=None,
|
|
server_kwargs=None,
|
|
client_kwargs=None,
|
|
):
|
|
if not loop:
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
server_kwargs = server_kwargs or {}
|
|
client_kwargs = client_kwargs or {}
|
|
|
|
Sanic.test_mode = True
|
|
self.app = app
|
|
self.host = host
|
|
self.port = port or randint(5000, 65000)
|
|
self._loop = loop
|
|
self.debug = False
|
|
self._server = None
|
|
self.app.state.server_info.append(
|
|
ApplicationServerInfo(
|
|
settings={
|
|
"version": "1.1",
|
|
"ssl": None,
|
|
"unix": None,
|
|
"sock": None,
|
|
"loop": None,
|
|
"host": self.host,
|
|
"port": self.port,
|
|
}
|
|
)
|
|
)
|
|
|
|
self._session = httpx.AsyncClient(verify=False, **client_kwargs)
|
|
self._server_co = self.app.create_server(
|
|
host=self.host,
|
|
debug=self.debug,
|
|
port=self.port,
|
|
return_asyncio_server=True,
|
|
**server_kwargs,
|
|
)
|
|
|
|
def __enter__(self):
|
|
self.run()
|
|
return self
|
|
|
|
def __exit__(self, *_):
|
|
self.stop()
|
|
|
|
def run(self):
|
|
self._loop._stopping = False
|
|
self.app.router.reset()
|
|
self.app.signal_router.reset()
|
|
self._run(self.app._startup())
|
|
self._run(self.app._server_event("init", "before", loop=self._loop))
|
|
self._server = self._run(self._server_co)
|
|
self._run(self.app._server_event("init", "after", loop=self._loop))
|
|
|
|
def stop(self):
|
|
self._run(
|
|
self.app._server_event("shutdown", "before", loop=self._loop)
|
|
)
|
|
if self._session:
|
|
self._run(self._session.aclose())
|
|
self._session = None
|
|
|
|
if self._server:
|
|
self._server.close()
|
|
self._run(self._server.wait_closed())
|
|
self._server = None
|
|
|
|
self._run(self.app._server_event("shutdown", "after", loop=self._loop))
|
|
|
|
def _sanic_endpoint_test(
|
|
self,
|
|
method: str = "get",
|
|
uri: str = "/",
|
|
gather_request: bool = True,
|
|
debug: bool = False,
|
|
server_kwargs: Optional[Dict[str, Any]] = None,
|
|
host: Optional[str] = None,
|
|
port: Optional[int] = None,
|
|
allow_none: bool = False,
|
|
*request_args,
|
|
**request_kwargs,
|
|
) -> Tuple[Optional[Request], Optional[TestingResponse]]:
|
|
request_data: Dict[str, Request] = {}
|
|
exceptions: List[Exception] = []
|
|
|
|
host = host or self.host
|
|
port = port or self.port
|
|
|
|
if gather_request:
|
|
_collect_request = partial(self._collect_request, request_data)
|
|
self.app.request_middleware.appendleft(_collect_request) # type: ignore # noqa
|
|
|
|
for route in self.app.router.routes:
|
|
if _collect_request not in route.extra.request_middleware:
|
|
route.extra.request_middleware.appendleft(_collect_request)
|
|
|
|
if uri.startswith(
|
|
("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:")
|
|
):
|
|
url = uri
|
|
else:
|
|
uri = uri if uri.startswith("/") else f"/{uri}"
|
|
scheme = "ws" if method == "websocket" else "http"
|
|
url = f"{scheme}://{host}:{port}{uri}"
|
|
|
|
if exceptions:
|
|
raise ValueError(f"Exception during request: {exceptions}")
|
|
|
|
response = self._run(
|
|
self._local_request(method, url, *request_args, **request_kwargs)
|
|
)
|
|
|
|
try:
|
|
self.app.request_middleware.remove(_collect_request) # type: ignore # noqa
|
|
except BaseException: # noqa
|
|
pass
|
|
|
|
try:
|
|
request = request_data.get("request") if gather_request else None
|
|
if response is None:
|
|
if not allow_none:
|
|
raise ValueError(
|
|
"No response returned to Sanic Test Client."
|
|
)
|
|
return request, response
|
|
except BaseException: # noqa
|
|
if not allow_none:
|
|
raise ValueError(
|
|
"Request and response object expected, "
|
|
f"got ({request}, {response})"
|
|
)
|
|
|
|
return None, None
|
|
|
|
async def _local_request(self, method, url, *args, **kwargs):
|
|
raw_cookies = kwargs.pop("raw_cookies", None)
|
|
|
|
if method == "websocket":
|
|
return await websocket_proxy(url, *args, **kwargs)
|
|
else:
|
|
session = self._session
|
|
|
|
try:
|
|
if method == "request":
|
|
args = tuple([url] + list(args))
|
|
url = kwargs.pop("http_method", "GET").upper()
|
|
response = await getattr(session, method.lower())(
|
|
url, *args, **kwargs
|
|
)
|
|
except httpx.HTTPError as e:
|
|
if hasattr(e, "response"):
|
|
response = getattr(e, "response")
|
|
else:
|
|
logger.error(
|
|
f"{method.upper()} {url} received no response!",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
response.__class__ = TestingResponse
|
|
|
|
if raw_cookies:
|
|
response.raw_cookies = {}
|
|
|
|
for cookie in response.cookies.jar:
|
|
response.raw_cookies[cookie.name] = cookie
|
|
|
|
return response
|
|
|
|
def _run(self, coro):
|
|
if not self._loop:
|
|
raise RuntimeError("Test client has no loop")
|
|
return self._loop.run_until_complete(coro)
|
|
|
|
@staticmethod
|
|
def _collect_request(data, request):
|
|
data["request"] = request
|
|
|
|
def request(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("request", *args, **kwargs)
|
|
|
|
def get(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("get", *args, **kwargs)
|
|
|
|
def post(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("post", *args, **kwargs)
|
|
|
|
def put(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("put", *args, **kwargs)
|
|
|
|
def delete(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("delete", *args, **kwargs)
|
|
|
|
def patch(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("patch", *args, **kwargs)
|
|
|
|
def options(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("options", *args, **kwargs)
|
|
|
|
def head(self, *args, **kwargs):
|
|
return self._sanic_endpoint_test("head", *args, **kwargs)
|
|
|
|
def websocket(
|
|
self,
|
|
*args,
|
|
mimic: typing.Optional[
|
|
typing.Callable[..., typing.Coroutine[None, None, typing.Any]]
|
|
] = None,
|
|
**kwargs,
|
|
):
|
|
kwargs["mimic"] = mimic
|
|
return self._sanic_endpoint_test("websocket", *args, **kwargs)
|