hack-house/.venv/lib/python3.12/site-packages/sanic_testing/testing.py
leetcrypt bb1d662ee1 chore: rename project coven → hack-house ⛧
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>
2026-05-30 13:29:14 -07:00

477 lines
15 KiB
Python

import typing
from functools import partial
from ipaddress import IPv6Address, ip_address
from json import JSONDecodeError
from socket import AF_INET6, SOCK_STREAM, socket
from string import ascii_lowercase
import httpx
from sanic import Sanic # type: ignore
from sanic.asgi import ASGIApp # type: ignore
from sanic.exceptions import MethodNotSupported, ServerError # type: ignore
from sanic.log import logger # type: ignore
from sanic.request import Request # type: ignore
from sanic.response import text # type: ignore
from sanic_testing.websocket import websocket_proxy
ASGI_HOST = "mockserver"
ASGI_PORT = 1234
ASGI_BASE_URL = f"http://{ASGI_HOST}:{ASGI_PORT}"
HOST = "127.0.0.1"
PORT = None
httpx_version = tuple(
map(int, httpx.__version__.strip(ascii_lowercase).split("."))
)
class TestingResponse(httpx.Response):
@property
def status(self):
return self.status_code
@property
def body(self):
return self.content
@property
def content_type(self):
return self.headers.get("content-type")
@property
def json(self):
if getattr(self, "_json", None):
return self._json
try:
self._json = super().json()
except (JSONDecodeError, UnicodeDecodeError):
self._json = None
return self._json
def _blank(*_, **__):
...
class SanicTestClient:
def __init__(
self, app: Sanic, port: typing.Optional[int] = PORT, host: str = HOST
) -> None:
"""Use port=None to bind to a random port"""
Sanic.test_mode = True
self.app = app
self.port = port
self.host = host
self._do_request = _blank
app.after_server_start(self._run_request)
def _run_request(self, *args, **kwargs):
return self._do_request(*args, **kwargs)
@classmethod
def _start_test_mode(cls, sanic, *args, **kwargs):
Sanic.test_mode = True
@classmethod
def _end_test_mode(cls, sanic, *args, **kwargs):
Sanic.test_mode = False
def get_new_session(self, **kwargs) -> httpx.AsyncClient:
return httpx.AsyncClient(verify=False, **kwargs)
async def _local_request(self, method: str, url: str, *args, **kwargs):
logger.info(url)
raw_cookies = kwargs.pop("raw_cookies", None)
session_kwargs = kwargs.pop("session_kwargs", {})
if httpx_version >= (0, 20) and method != "websocket":
kwargs["follow_redirects"] = True
allow_redirects = kwargs.pop("allow_redirects", None)
if allow_redirects is not None:
kwargs["follow_redirects"] = allow_redirects
if method == "websocket":
return await websocket_proxy(url, *args, **kwargs)
else:
async with self.get_new_session(**session_kwargs) as 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
@classmethod
def _collect_request(cls, results, request):
if results[0] is None:
results[0] = request
async def _collect_response(
self,
method,
url,
exceptions,
results,
sanic,
loop,
**request_kwargs,
):
try:
response = await self._local_request(method, url, **request_kwargs)
results[-1] = response
if method == "websocket":
await response.ws.close()
except Exception as e:
logger.exception("Exception")
exceptions.append(e)
finally:
self.app.stop()
async def _error_handler(self, request, exception):
if request.method in ["HEAD", "PATCH", "PUT", "DELETE"]:
return text("", exception.status_code, headers=exception.headers)
else:
return self.app.error_handler.default(request, exception)
def _sanic_endpoint_test(
self,
method: str = "get",
uri: str = "/",
gather_request: bool = True,
debug: bool = False,
server_kwargs: typing.Optional[typing.Dict[str, typing.Any]] = None,
host: typing.Optional[str] = None,
allow_none: bool = False,
*request_args,
**request_kwargs,
) -> typing.Tuple[
typing.Optional[Request], typing.Optional[TestingResponse]
]:
results = [None, None]
exceptions: typing.List[Exception] = []
server_kwargs = server_kwargs or {"auto_reload": False}
_collect_request = partial(self._collect_request, results)
self.app.router.reset()
self.app.signal_router.reset()
if gather_request:
self.app.request_middleware.appendleft(_collect_request) # type: ignore # noqa
try:
self.app.exception(MethodNotSupported)(self._error_handler)
except ServerError:
...
if self.port:
server_kwargs = dict(
host=host or self.host,
port=self.port,
**server_kwargs,
)
host, port = host or self.host, self.port
else:
bind = host or self.host
ip = ip_address(bind)
if isinstance(ip, IPv6Address):
sock = socket(AF_INET6, SOCK_STREAM)
port = ASGI_PORT
else:
sock = socket()
port = 0
sock.bind((bind, port))
server_kwargs = dict(sock=sock, **server_kwargs)
if isinstance(ip, IPv6Address):
host, port, _, _ = sock.getsockname()
host = f"[{host}]"
else:
host, port = sock.getsockname()
self.port = port
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}"
# Tests construct URLs using PORT = None, which means random port not
# known until this function is called, so fix that here
url = url.replace(":None/", f":{port}/")
self._do_request = partial(
self._collect_response,
method,
url,
exceptions,
results,
**request_kwargs,
)
self.app.run( # type: ignore
debug=debug,
single_process=True,
**server_kwargs,
)
if exceptions:
raise ValueError(f"Exception during request: {exceptions}")
if gather_request:
try:
self.app.request_middleware.remove(_collect_request) # type: ignore # noqa
except BaseException: # noqa
pass
try:
request, response = results
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 ({results})"
)
return None, None
else:
try:
if results[-1] is None:
if not allow_none:
raise ValueError(
"No response returned to Sanic Test Client."
)
return None, results[-1]
except BaseException: # noqa
if not allow_none:
raise ValueError(
f"Request object expected, got ({results})"
)
return None, None
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)
class TestASGIApp(ASGIApp):
async def __call__(self):
await super().__call__()
return self.request
async def app_call_with_return(self, scope, receive, send):
asgi_app = await TestASGIApp.create(self, scope, receive, send)
return await asgi_app()
class SanicASGITestClient(httpx.AsyncClient):
def __init__(
self,
app: Sanic,
base_url: str = ASGI_BASE_URL,
suppress_exceptions: bool = False,
) -> None:
Sanic.test_mode = True
app.__class__.__call__ = app_call_with_return # type: ignore
app.asgi = True
self.sanic_app = app
transport = httpx.ASGITransport(app=app, client=(ASGI_HOST, ASGI_PORT))
super().__init__(transport=transport, base_url=base_url)
self.gather_request = True
self.last_request = None
def _collect_request(self, request):
if self.gather_request:
self.last_request = request
else:
self.last_request = None
@classmethod
def _start_test_mode(cls, sanic, *args, **kwargs):
Sanic.test_mode = True
@classmethod
def _end_test_mode(cls, sanic, *args, **kwargs):
Sanic.test_mode = False
async def request( # type: ignore
self, method, url, gather_request=True, *args, **kwargs
) -> typing.Tuple[
typing.Optional[Request], typing.Optional[TestingResponse]
]:
self.sanic_app.router.reset()
self.sanic_app.signal_router.reset()
await self.sanic_app._startup() # type: ignore
await self.sanic_app._server_event("init", "before")
await self.sanic_app._server_event("init", "after")
for route in self.sanic_app.router.routes:
if self._collect_request not in route.extra.request_middleware:
route.extra.request_middleware.appendleft(
self._collect_request
)
if not url.startswith(
("http:", "https:", "ftp:", "ftps://", "//", "ws:", "wss:")
):
url = url if url.startswith("/") else f"/{url}"
scheme = "ws" if method == "websocket" else "http"
url = f"{scheme}://{ASGI_HOST}:{ASGI_PORT}{url}"
if self._collect_request not in self.sanic_app.request_middleware:
self.sanic_app.request_middleware.appendleft(
self._collect_request # type: ignore
)
self.gather_request = gather_request
response = await super().request(method, url, *args, **kwargs)
await self.sanic_app._server_event("shutdown", "before")
await self.sanic_app._server_event("shutdown", "after")
response.__class__ = TestingResponse
if gather_request:
return self.last_request, response # type: ignore
return None, response # type: ignore
@classmethod
async def _ws_receive(cls):
return {}
@classmethod
async def _ws_send(cls, message):
pass
async def websocket(
self,
uri,
subprotocols=None,
*args,
mimic: typing.Optional[
typing.Callable[..., typing.Coroutine[None, None, typing.Any]]
] = None,
**kwargs,
):
if mimic:
raise RuntimeError(
"SanicASGITestClient does not currently support the mimic "
"keyword argument. Please use SanicTestClient instead."
)
scheme = "ws"
path = uri
root_path = f"{scheme}://{ASGI_HOST}:{ASGI_PORT}"
headers = kwargs.get("headers", {})
headers.setdefault("connection", "upgrade")
headers.setdefault("sec-websocket-key", "testserver==")
headers.setdefault("sec-websocket-version", "13")
if subprotocols is not None:
headers.setdefault(
"sec-websocket-protocol", ", ".join(subprotocols)
)
scope = {
"type": "websocket",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"headers": [map(lambda y: y.encode(), x) for x in headers.items()],
"scheme": scheme,
"root_path": root_path,
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"subprotocols": subprotocols,
}
self.sanic_app.router.reset()
self.sanic_app.signal_router.reset()
await self.sanic_app._startup()
await self.sanic_app(scope, self._ws_receive, self._ws_send)
return None, {"opened": True}
def __getstate__(self):
# Cookies cannot be pickled, because they contain a ThreadLock
try:
del self._cookies
except AttributeError:
pass
return self.__dict__
def __setstate__(self, d):
try:
del d["_cookies"]
except LookupError:
pass
self.__dict__.update(d)
# Need to create a new CookieJar when unpickling,
# because it was killed on Pickle
self._cookies = httpx.Cookies()