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>
310 lines
9.0 KiB
Python
310 lines
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
import atexit
|
|
import concurrent.futures
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
|
|
|
|
try:
|
|
import termios
|
|
|
|
TERMIOS_AVAILABLE = True
|
|
except ImportError:
|
|
TERMIOS_AVAILABLE = False
|
|
termios = None # type: ignore
|
|
|
|
from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT
|
|
from asyncio import iscoroutine, new_event_loop
|
|
from code import InteractiveConsole
|
|
from collections.abc import Sequence
|
|
from contextlib import suppress
|
|
from types import FunctionType
|
|
from typing import Any, NamedTuple
|
|
|
|
import sanic
|
|
|
|
from sanic import Request, Sanic
|
|
from sanic.compat import Header
|
|
from sanic.helpers import Default
|
|
from sanic.http.constants import Stage
|
|
from sanic.log import Colors
|
|
from sanic.models.ctx_types import REPLContext
|
|
from sanic.models.protocol_types import TransportProtocol
|
|
from sanic.response.types import HTTPResponse
|
|
|
|
|
|
try:
|
|
from httpx import Client
|
|
|
|
HTTPX_AVAILABLE = True
|
|
|
|
class SanicClient(Client):
|
|
def __init__(self, app: Sanic):
|
|
base_url = app.get_server_location(
|
|
app.state.server_info[0].settings
|
|
)
|
|
super().__init__(base_url=base_url)
|
|
|
|
except ImportError:
|
|
HTTPX_AVAILABLE = False
|
|
|
|
try:
|
|
import readline # noqa
|
|
except ImportError:
|
|
print(
|
|
"Module 'readline' not available. History navigation will be limited.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
repl_app: Sanic | None = None
|
|
repl_response: HTTPResponse | None = None
|
|
|
|
|
|
class REPLProtocol(TransportProtocol):
|
|
def __init__(self):
|
|
self.stage = Stage.IDLE
|
|
self.request_body = True
|
|
|
|
def respond(self, response):
|
|
global repl_response
|
|
repl_response = response
|
|
response.stream = self
|
|
return response
|
|
|
|
async def send(self, data, end_stream): ...
|
|
|
|
|
|
class Result(NamedTuple):
|
|
request: Request
|
|
response: HTTPResponse
|
|
|
|
|
|
def make_request(
|
|
url: str = "/",
|
|
headers: dict[str, Any] | Sequence[tuple[str, str]] | None = None,
|
|
method: str = "GET",
|
|
body: str | None = None,
|
|
):
|
|
assert repl_app, "No Sanic app has been registered."
|
|
headers = headers or {}
|
|
protocol = REPLProtocol()
|
|
request = Request( # type: ignore
|
|
url.encode(),
|
|
Header(headers),
|
|
"1.1",
|
|
method,
|
|
protocol,
|
|
repl_app,
|
|
)
|
|
if body is not None:
|
|
request.body = body.encode()
|
|
request.stream = protocol # type: ignore
|
|
request.conn_info = None
|
|
return request
|
|
|
|
|
|
async def respond(request) -> HTTPResponse:
|
|
assert repl_app, "No Sanic app has been registered."
|
|
await repl_app.handle_request(request)
|
|
assert repl_response
|
|
return repl_response
|
|
|
|
|
|
async def do(
|
|
url: str = "/",
|
|
headers: dict[str, Any] | Sequence[tuple[str, str]] | None = None,
|
|
method: str = "GET",
|
|
body: str | None = None,
|
|
) -> Result:
|
|
request = make_request(url, headers, method, body)
|
|
response = await respond(request)
|
|
return Result(request, response)
|
|
|
|
|
|
def _variable_description(name: str, desc: str, type_desc: str) -> str:
|
|
return (
|
|
f" - {Colors.BOLD + Colors.SANIC}{name}{Colors.END}: {desc} - "
|
|
f"{Colors.BOLD + Colors.BLUE}{type_desc}{Colors.END}"
|
|
)
|
|
|
|
|
|
class SanicREPL(InteractiveConsole):
|
|
def __init__(self, app: Sanic, start: Default | None = None):
|
|
global repl_app
|
|
repl_app = app
|
|
locals_available = {
|
|
"app": app,
|
|
"sanic": sanic,
|
|
"do": do,
|
|
}
|
|
|
|
user_locals = {
|
|
user_local.name: user_local.var for user_local in app.repl_ctx
|
|
}
|
|
|
|
client_availability = ""
|
|
variable_descriptions = [
|
|
_variable_description(
|
|
"app", REPLContext.BUILTINS["app"], str(app)
|
|
),
|
|
_variable_description(
|
|
"sanic", REPLContext.BUILTINS["sanic"], "import sanic"
|
|
),
|
|
_variable_description(
|
|
"do", REPLContext.BUILTINS["do"], "Result(request, response)"
|
|
),
|
|
]
|
|
|
|
user_locals_descriptions = [
|
|
_variable_description(
|
|
user_local.name, user_local.desc, str(type(user_local.var))
|
|
)
|
|
for user_local in app.repl_ctx
|
|
]
|
|
|
|
if HTTPX_AVAILABLE:
|
|
locals_available["client"] = SanicClient(app)
|
|
variable_descriptions.append(
|
|
_variable_description(
|
|
"client",
|
|
REPLContext.BUILTINS["client"],
|
|
"from httpx import Client",
|
|
),
|
|
)
|
|
else:
|
|
client_availability = (
|
|
f"\n{Colors.YELLOW}The HTTP client has been disabled. "
|
|
"To enable it, install httpx:\n\t"
|
|
f"pip install httpx{Colors.END}\n"
|
|
)
|
|
super().__init__(locals={**locals_available, **user_locals})
|
|
self.compile.compiler.flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT
|
|
self.loop = new_event_loop()
|
|
self._start = start
|
|
self._pause_event = threading.Event()
|
|
self._started_event = threading.Event()
|
|
self._interact_thread = threading.Thread(
|
|
target=self._console,
|
|
daemon=True,
|
|
)
|
|
self._monitor_thread = threading.Thread(
|
|
target=self._monitor,
|
|
daemon=True,
|
|
)
|
|
self._async_thread = threading.Thread(
|
|
target=self.loop.run_forever,
|
|
daemon=True,
|
|
)
|
|
self.app = app
|
|
self.resume()
|
|
self.exit_message = "Closing the REPL."
|
|
self.banner_message = "\n".join(
|
|
[
|
|
f"\n{Colors.BOLD}Welcome to the Sanic interactive console{Colors.END}", # noqa: E501
|
|
client_availability,
|
|
"The following objects are available for your convenience:", # noqa: E501
|
|
*variable_descriptions,
|
|
]
|
|
+ (
|
|
[
|
|
"\nREPL Context:",
|
|
*user_locals_descriptions,
|
|
]
|
|
if user_locals_descriptions
|
|
else []
|
|
)
|
|
+ [
|
|
"\nThe async/await keywords are available for use here.", # noqa: E501
|
|
f"To exit, press {Colors.BOLD}CTRL+C{Colors.END}, "
|
|
f"{Colors.BOLD}CTRL+D{Colors.END}, or type {Colors.BOLD}exit(){Colors.END}.\n", # noqa: E501
|
|
]
|
|
)
|
|
|
|
def pause(self):
|
|
if self.is_paused():
|
|
return
|
|
self._pause_event.clear()
|
|
|
|
def resume(self):
|
|
self._pause_event.set()
|
|
|
|
def runsource(self, source, filename="<input>", symbol="single"):
|
|
if source.strip() == "exit()":
|
|
self._shutdown()
|
|
return False
|
|
|
|
if self.is_paused():
|
|
print("Console is paused. Please wait for it to be resumed.")
|
|
return False
|
|
|
|
return super().runsource(source, filename, symbol)
|
|
|
|
def runcode(self, code):
|
|
future = concurrent.futures.Future()
|
|
|
|
async def callback():
|
|
func = FunctionType(code, self.locals)
|
|
try:
|
|
result = func()
|
|
if iscoroutine(result):
|
|
result = await result
|
|
except BaseException:
|
|
traceback.print_exc()
|
|
result = False
|
|
future.set_result(result)
|
|
|
|
self.loop.call_soon_threadsafe(self.loop.create_task, callback())
|
|
return future.result()
|
|
|
|
def is_paused(self):
|
|
return not self._pause_event.is_set()
|
|
|
|
def _console(self):
|
|
self._started_event.set()
|
|
self.interact(banner=self.banner_message, exitmsg=self.exit_message)
|
|
self._shutdown()
|
|
|
|
def _setup_terminal(self):
|
|
assert termios is not None
|
|
with suppress(termios.error, AttributeError):
|
|
fd = sys.stdin.fileno()
|
|
old_attrs = termios.tcgetattr(fd)
|
|
atexit.register(
|
|
termios.tcsetattr, fd, termios.TCSADRAIN, old_attrs
|
|
)
|
|
|
|
def _monitor(self):
|
|
if isinstance(self._start, Default):
|
|
if TERMIOS_AVAILABLE and sys.stdin.isatty():
|
|
self._setup_terminal()
|
|
enter = f"{Colors.BOLD + Colors.SANIC}ENTER{Colors.END}"
|
|
start = input(f"\nPress {enter} at anytime to start the REPL.\n\n")
|
|
if start:
|
|
return
|
|
try:
|
|
while True:
|
|
if not self._started_event.is_set():
|
|
self.app.manager.wait_for_ack()
|
|
self._interact_thread.start()
|
|
elif self.app.manager._all_workers_ack() and self.is_paused():
|
|
self.resume()
|
|
print(sys.ps1, end="", flush=True)
|
|
elif (
|
|
not self.app.manager._all_workers_ack()
|
|
and not self.is_paused()
|
|
):
|
|
self.pause()
|
|
time.sleep(0.1)
|
|
except (ConnectionResetError, BrokenPipeError):
|
|
pass
|
|
|
|
def _shutdown(self):
|
|
self.app.manager.monitor_publisher.send("__TERMINATE__")
|
|
|
|
def run(self):
|
|
self._monitor_thread.start()
|
|
self._async_thread.start()
|