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>
This commit is contained in:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -0,0 +1,499 @@
from __future__ import annotations
import os
import shutil
import sys
from argparse import Namespace
from functools import partial
from textwrap import indent
from sanic.app import Sanic
from sanic.application.logo import get_logo
from sanic.cli.arguments import Group
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.console import SanicREPL
from sanic.cli.daemon import (
kill_daemon,
make_kill_parser,
make_restart_parser,
make_status_parser,
resolve_target,
restart_daemon,
status_daemon,
stop_daemon,
)
from sanic.cli.executor import Executor, make_executor_parser
from sanic.cli.inspector import make_inspector_parser
from sanic.cli.inspector_client import InspectorClient
from sanic.compat import OS_IS_WINDOWS
from sanic.helpers import _default, is_atty
from sanic.log import error_logger
from sanic.worker.daemon import Daemon, DaemonError
from sanic.worker.loader import AppLoader
class SanicCLI:
DESCRIPTION_SHORT = indent(
f"""
{get_logo(True)}
Usage:
sanic <target> [options] Run a Sanic application
sanic <target> exec <cmd> Execute a command in app context
sanic inspect [options] Inspect a running instance
sanic help [--full] Show help (--full for all options)
Examples:
sanic path.to.server:app Run app
sanic path.to.server --dev Run in development mode
sanic ./static --simple Serve static files
""",
prefix=" ",
)
DESCRIPTION_SHORT_FOOTER = """
(additional options available)
For complete options and documentation:
sanic help --full
"""
DESCRIPTION_FULL = indent(
f"""
{get_logo(True)}
To start running a Sanic application, provide a path to the module, where
app is a Sanic() instance in the global scope:
$ sanic path.to.server:app
If the Sanic instance variable is called 'app', you can leave off the last
part, and only provide a path to the module where the instance is:
$ sanic path.to.server
Or, a path to a callable that returns a Sanic() instance:
$ sanic path.to.factory:create_app
Or, a path to a directory to run as a simple HTTP server:
$ sanic ./path/to/static
Additional commands:
$ sanic inspect ... Inspect a running Sanic instance
$ sanic path.to.app exec ... Run app commands
$ sanic path.to.app status Check if app daemon is running
$ sanic path.to.app restart Restart app daemon (future use)
$ sanic path.to.app stop Stop app daemon
Advanced daemon management:
$ sanic kill (--pid PID | --pidfile PATH) Force kill (SIGKILL)
$ sanic status (--pid PID | --pidfile PATH) Check status
$ sanic restart (--pid PID | --pidfile PATH) Restart (future use)
""",
prefix=" ",
)
def __init__(self) -> None:
width = shutil.get_terminal_size().columns
self.parser = SanicArgumentParser(
prog="sanic",
description=self.DESCRIPTION_SHORT,
formatter_class=lambda prog: SanicHelpFormatter(
prog,
max_help_position=36 if width > 96 else 24,
indent_increment=4,
width=None,
),
)
self.parser._positionals.title = "Required\n========\n Positional"
self.parser._optionals.title = "Optional\n========\n General"
self.main_process = (
os.environ.get("SANIC_RELOADER_PROCESS", "") != "true"
)
self.args: Namespace = Namespace()
self.groups: list[Group] = []
self.run_mode = "serve"
def attach(self):
if len(sys.argv) > 1 and sys.argv[1] == "help":
self.run_mode = "help"
return
if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
self.run_mode = "help"
return
if len(sys.argv) > 1 and sys.argv[1] == "inspect":
self.run_mode = "inspect"
self.parser.description = get_logo(True)
make_inspector_parser(self.parser)
return
if not OS_IS_WINDOWS and len(sys.argv) > 1 and sys.argv[1] == "kill":
self.run_mode = "kill"
self.parser.description = get_logo(True)
make_kill_parser(self.parser)
return
if not OS_IS_WINDOWS and len(sys.argv) > 1 and sys.argv[1] == "status":
self.run_mode = "status"
self.parser.description = get_logo(True)
make_status_parser(self.parser)
return
if (
not OS_IS_WINDOWS
and len(sys.argv) > 1
and sys.argv[1] == "restart"
):
self.run_mode = "restart"
self.parser.description = get_logo(True)
make_restart_parser(self.parser)
return
# Check for app-based daemon commands: sanic <app> status|restart|stop
if (
not OS_IS_WINDOWS
and len(sys.argv) > 2
and sys.argv[2]
in (
"status",
"restart",
"stop",
)
):
self.run_mode = f"app_{sys.argv[2]}"
for group in Group._registry:
instance = group.create(self.parser)
instance.attach()
self.groups.append(instance)
if len(sys.argv) > 2 and sys.argv[2] == "exec":
self.run_mode = "exec"
self.parser.description = get_logo(True)
make_executor_parser(self.parser)
def run(self, parse_args=None):
if self.run_mode == "inspect":
self._inspector()
return
if self.run_mode == "kill":
self._kill()
return
if self.run_mode == "status":
self._status()
return
if self.run_mode == "restart":
self._restart()
return
if self.run_mode == "help":
self._help()
return
if self.run_mode.startswith("app_"):
self._app_daemon_command()
return
legacy_version = False
if not parse_args:
# This is to provide backwards compat -v to display version
legacy_version = len(sys.argv) == 2 and sys.argv[-1] == "-v"
parse_args = ["--version"] if legacy_version else None
elif parse_args == ["-v"]:
parse_args = ["--version"]
if not legacy_version:
if self.run_mode == "exec":
parse_args = [
a
for a in (parse_args or sys.argv[1:])
if a not in "-h --help".split()
]
parsed, unknown = self.parser.parse_known_args(args=parse_args)
if unknown and parsed.factory:
for arg in unknown:
if arg.startswith("--"):
self.parser.add_argument(arg.split("=")[0])
if self.run_mode == "exec":
self.args, _ = self.parser.parse_known_args(args=parse_args)
else:
self.args = self.parser.parse_args(args=parse_args)
self._precheck()
app_loader = AppLoader(
self.args.target, self.args.factory, self.args.simple, self.args
)
try:
app = self._get_app(app_loader)
kwargs = self._build_run_kwargs()
except ValueError as e:
error_logger.exception(f"Failed to run app: {e}")
else:
if self.run_mode == "exec":
self._executor(app, kwargs)
return
elif self.run_mode != "serve":
raise ValueError(f"Unknown run mode: {self.run_mode}")
daemon = None
if getattr(self.args, "daemon", False):
daemon = self._setup_daemon(app.name)
if daemon:
lines = ["Starting Sanic in daemon mode..."]
if daemon.pidfile:
lines.append(f" PID file: {daemon.pidfile}")
if daemon.logfile:
lines.append(f" Logs: {daemon.logfile}")
print("\n".join(lines), flush=True)
daemon.daemonize()
if self.args.repl:
self._repl(app)
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
if daemon:
daemon.drop_privileges()
if self.args.single:
serve = Sanic.serve_single
else:
serve = partial(Sanic.serve, app_loader=app_loader)
serve(app)
def _inspector(self):
args = sys.argv[2:]
self.args, unknown = self.parser.parse_known_args(args=args)
if unknown:
for arg in unknown:
if arg.startswith("--"):
try:
key, value = arg.split("=")
key = key.lstrip("-")
except ValueError:
value = False if arg.startswith("--no-") else True
key = (
arg.replace("--no-", "")
.lstrip("-")
.replace("-", "_")
)
setattr(self.args, key, value)
kwargs = {**self.args.__dict__}
host = kwargs.pop("host")
port = kwargs.pop("port")
secure = kwargs.pop("secure")
raw = kwargs.pop("raw")
action = kwargs.pop("action") or "info"
api_key = kwargs.pop("api_key")
positional = kwargs.pop("positional", None)
if action == "<custom>" and positional:
action = positional[0]
if len(positional) > 1:
kwargs["args"] = positional[1:]
InspectorClient(host, port, secure, raw, api_key).do(action, **kwargs)
def _kill(self):
self.args = self.parser.parse_args(args=sys.argv[2:])
pid, pidfile = resolve_target(self.args.pid, self.args.pidfile)
kill_daemon(pid, pidfile)
def _status(self):
self.args = self.parser.parse_args(args=sys.argv[2:])
pid, pidfile = resolve_target(self.args.pid, self.args.pidfile)
status_daemon(pid, pidfile)
def _restart(self):
self.args = self.parser.parse_args(args=sys.argv[2:])
pid, _ = resolve_target(self.args.pid, self.args.pidfile)
restart_daemon(pid)
def _help(self):
full = "--full" in sys.argv
if full:
self.parser.description = self.DESCRIPTION_FULL
for group in Group._registry:
instance = group.create(self.parser)
instance.attach(short=not full)
self.groups.append(instance)
self.parser.print_help()
if not full:
print(self.DESCRIPTION_SHORT_FOOTER)
def _app_daemon_command(self):
"""Handle app-based daemon commands: sanic <app> status|restart|stop"""
command = self.run_mode.replace("app_", "")
# Parse just the app target (first arg)
self.args = self.parser.parse_args(args=[sys.argv[1]])
app_loader = AppLoader(
self.args.target, self.args.factory, self.args.simple, self.args
)
try:
app = self._get_app(app_loader)
except (ImportError, ValueError) as e:
error_logger.error(f"Failed to load app: {e}")
sys.exit(1)
pidfile = Daemon.get_pidfile_path(app.name)
pid, pidfile = resolve_target(None, str(pidfile))
if command == "status":
status_daemon(pid, pidfile)
elif command == "restart":
restart_daemon(pid)
elif command == "stop":
force = "-f" in sys.argv or "--force" in sys.argv
stop_daemon(pid, pidfile, force)
def _executor(self, app: Sanic, kwargs: dict):
args = sys.argv[3:]
Executor(app, kwargs).run(self.args.command, args)
def _repl(self, app: Sanic):
if is_atty():
@app.main_process_ready
async def start_repl(app):
SanicREPL(app, self.args.repl).run()
await app._startup()
elif self.args.repl is True:
error_logger.error(
"Can't start REPL in non-interactive mode. "
"You can only run with --repl in a TTY."
)
def _setup_daemon(self, app_name: str):
if OS_IS_WINDOWS:
error_logger.error(
"Daemon mode is not supported on Windows. "
"Consider using a Windows service or nssm instead."
)
sys.exit(1)
if getattr(self.args, "dev", False) or getattr(
self.args, "auto_reload", False
):
error_logger.error(
"Daemon mode is not compatible with --dev or --auto-reload"
)
sys.exit(1)
if getattr(self.args, "repl", False):
error_logger.error("Daemon mode is not compatible with --repl")
sys.exit(1)
try:
daemon = Daemon(
pidfile=getattr(self.args, "pidfile", None) or "auto",
logfile=getattr(self.args, "logfile", None),
user=getattr(self.args, "daemon_user", None),
group=getattr(self.args, "daemon_group", None),
name=app_name,
)
return daemon
except DaemonError as e:
error_logger.error(f"Daemon configuration error: {e}")
sys.exit(1)
def _precheck(self):
# Custom TLS mismatch handling for better diagnostics
if self.main_process and (
# one of cert/key missing
bool(self.args.cert) != bool(self.args.key)
# new and old style self.args used together
or self.args.tls
and self.args.cert
# strict host checking without certs would always fail
or self.args.tlshost
and not self.args.tls
and not self.args.cert
):
self.parser.print_usage(sys.stderr)
message = (
"TLS certificates must be specified by either of:\n"
" --cert certdir/fullchain.pem --key certdir/privkey.pem\n"
" --tls certdir (equivalent to the above)"
)
error_logger.error(message)
sys.exit(1)
def _get_app(self, app_loader: AppLoader):
try:
app = app_loader.load()
except ImportError as e:
if app_loader.module_name.startswith(e.name): # type: ignore
error_logger.error(
f"No module named {e.name} found.\n"
" Example File: project/sanic_server.py -> app\n"
" Example Module: project.sanic_server.app"
)
error_logger.error(
"\nThe error below might have caused the above one:\n"
f"{e.msg}"
)
sys.exit(1)
else:
raise e
return app
def _build_run_kwargs(self):
for group in self.groups:
group.prepare(self.args)
ssl: None | dict | str | list = []
if self.args.tlshost:
ssl.append(None)
if self.args.cert is not None or self.args.key is not None:
ssl.append(dict(cert=self.args.cert, key=self.args.key))
if self.args.tls:
ssl += self.args.tls
if not ssl:
ssl = None
elif len(ssl) == 1 and ssl[0] is not None:
# Use only one cert, no TLSSelector.
ssl = ssl[0]
kwargs = {
"access_log": self.args.access_log,
"coffee": self.args.coffee,
"debug": self.args.debug,
"fast": self.args.fast,
"host": self.args.host,
"motd": self.args.motd,
"noisy_exceptions": self.args.noisy_exceptions,
"port": self.args.port,
"ssl": ssl,
"unix": self.args.unix,
"verbosity": self.args.verbosity or 0,
"workers": self.args.workers,
"auto_tls": self.args.auto_tls,
"single_process": self.args.single,
}
for maybe_arg in ("auto_reload", "dev"):
if getattr(self.args, maybe_arg, False):
kwargs[maybe_arg] = True
if self.args.dev and all(
arg not in sys.argv for arg in ("--repl", "--no-repl")
):
self.args.repl = _default
if self.args.path:
kwargs["auto_reload"] = True
kwargs["reload_dir"] = self.args.path
return kwargs
@@ -0,0 +1,431 @@
from __future__ import annotations
from argparse import ArgumentParser, _ArgumentGroup
from sanic_routing import __version__ as __routing_version__
from sanic import __version__
from sanic.compat import OS_IS_WINDOWS
from sanic.http.constants import HTTP
class Group:
name: str | None
container: ArgumentParser | _ArgumentGroup
_registry: list[type[Group]] = []
def __init_subclass__(cls) -> None:
Group._registry.append(cls)
def __init__(self, parser: ArgumentParser, title: str | None):
self.parser = parser
if title:
self.container = self.parser.add_argument_group(title=f" {title}")
else:
self.container = self.parser
@classmethod
def create(cls, parser: ArgumentParser):
instance = cls(parser, cls.name)
return instance
def add_bool_arguments(
self,
*args,
nullable=False,
help: str,
negative_help: str | None = None,
**kwargs,
):
group = self.container.add_mutually_exclusive_group()
pos_help = help[0].upper() + help[1:]
neg_help = (
negative_help if negative_help else f"Disable {help.lower()}"
)
group.add_argument(
*args,
action="store_true",
help=pos_help,
**kwargs,
)
group.add_argument(
"--no-" + args[0][2:],
*args[1:],
action="store_false",
help=neg_help[0].upper() + neg_help[1:],
**kwargs,
)
if nullable:
group.set_defaults(**{args[0][2:].replace("-", "_"): None})
def prepare(self, args) -> None: ...
def attach(self, short: bool = False) -> None: ...
class GeneralGroup(Group):
name = None
def attach(self, short: bool = False):
if short:
return
self.container.add_argument(
"--version",
action="version",
version=f"Sanic {__version__}; Routing {__routing_version__}",
)
self.container.add_argument(
"target",
help=(
"Path to your Sanic app instance.\n"
"\tExample: path.to.server:app\n"
"If running a Simple Server, path to directory to serve.\n"
"\tExample: ./\n"
"Additionally, this can be a path to a factory function\n"
"that returns a Sanic app instance.\n"
"\tExample: path.to.server:create_app\n"
),
)
choices = ["serve", "exec"]
help_text = (
"Action to perform.\n"
"\tserve: Run the Sanic app [default]\n"
"\texec: Execute a command in the Sanic app context\n"
)
if not OS_IS_WINDOWS:
choices.extend(["status", "restart", "stop"])
help_text += (
"\tstatus: Check if daemon is running\n"
"\trestart: Restart daemon (future use)\n"
"\tstop: Stop daemon gracefully\n"
)
self.container.add_argument(
"action",
nargs="?",
default="serve",
choices=choices,
help=help_text,
)
class ApplicationGroup(Group):
name = "Application"
def attach(self, short: bool = False):
if short:
return
group = self.container.add_mutually_exclusive_group()
group.add_argument(
"--factory",
action="store_true",
help=(
"Treat app as an application factory, "
"i.e. a () -> <Sanic app> callable"
),
)
group.add_argument(
"-s",
"--simple",
dest="simple",
action="store_true",
help=(
"Run Sanic as a Simple Server, and serve the contents of "
"a directory\n(module arg should be a path)"
),
)
self.add_bool_arguments(
"--repl",
help="Run with an interactive shell session",
negative_help="Disable interactive shell session",
)
class SocketGroup(Group):
name = "Socket binding"
def attach(self, short: bool = False):
self.container.add_argument(
"-H",
"--host",
dest="host",
type=str,
help="Host address [default 127.0.0.1]",
)
self.container.add_argument(
"-p",
"--port",
dest="port",
type=int,
help="Port to serve on [default 8000]",
)
if not OS_IS_WINDOWS and not short:
self.container.add_argument(
"-u",
"--unix",
dest="unix",
type=str,
default="",
help="location of UNIX socket",
)
class HTTPVersionGroup(Group):
name = "HTTP version"
def attach(self, short: bool = False):
if short:
return
http_values = [http.value for http in HTTP.__members__.values()]
self.container.add_argument(
"--http",
dest="http",
action="append",
choices=http_values,
type=int,
help=(
"Which HTTP version to use: HTTP/1.1 or HTTP/3. Value should\n"
"be either 1, or 3. [default 1]"
),
)
self.container.add_argument(
"-1",
dest="http",
action="append_const",
const=1,
help=("Run Sanic server using HTTP/1.1"),
)
self.container.add_argument(
"-3",
dest="http",
action="append_const",
const=3,
help=("Run Sanic server using HTTP/3"),
)
def prepare(self, args):
if not args.http:
args.http = [1]
args.http = tuple(sorted(set(map(HTTP, args.http)), reverse=True))
class TLSGroup(Group):
name = "TLS certificate"
def attach(self, short: bool = False):
if short:
return
self.container.add_argument(
"--cert",
dest="cert",
type=str,
help="Location of fullchain.pem, bundle.crt or equivalent",
)
self.container.add_argument(
"--key",
dest="key",
type=str,
help="Location of privkey.pem or equivalent .key file",
)
self.container.add_argument(
"--tls",
metavar="DIR",
type=str,
action="append",
help=(
"TLS certificate folder with fullchain.pem and privkey.pem\n"
"May be specified multiple times to choose multiple "
"certificates"
),
)
self.container.add_argument(
"--tls-strict-host",
dest="tlshost",
action="store_true",
help="Only allow clients that send an SNI matching server certs",
)
class DevelopmentGroup(Group):
name = "Development"
def attach(self, short: bool = False):
if not short:
self.container.add_argument(
"--debug",
dest="debug",
action="store_true",
help="Run the server in debug mode",
)
self.container.add_argument(
"-r",
"--reload",
"--auto-reload",
dest="auto_reload",
action="store_true",
help="Auto-reload on source changes",
)
self.container.add_argument(
"-R",
"--reload-dir",
dest="path",
action="append",
help="Additional directories to watch for changes",
)
self.container.add_argument(
"-d",
"--dev",
dest="dev",
action="store_true",
help="Run in development mode (debug + auto-reload)",
)
if not short:
self.container.add_argument(
"--auto-tls",
dest="auto_tls",
action="store_true",
help=(
"Create a temporary TLS certificate for local development "
"(requires mkcert or trustme)"
),
)
class WorkerGroup(Group):
name = "Worker"
def attach(self, short: bool = False):
if short:
return
group = self.container.add_mutually_exclusive_group()
group.add_argument(
"-w",
"--workers",
dest="workers",
type=int,
default=1,
help="Number of worker processes [default 1]",
)
group.add_argument(
"--fast",
dest="fast",
action="store_true",
help="Set the number of workers to max allowed",
)
group.add_argument(
"--single-process",
dest="single",
action="store_true",
help="Do not use multiprocessing, run server in a single process",
)
self.add_bool_arguments(
"--access-logs",
dest="access_log",
help="display access logs",
default=None,
)
class OutputGroup(Group):
name = "Output"
def attach(self, short: bool = False):
if short:
return
self.add_bool_arguments(
"--coffee",
dest="coffee",
default=False,
help="Uhm, coffee?",
negative_help="No coffee? Is that a typo?",
)
self.add_bool_arguments(
"--motd",
dest="motd",
default=True,
help="Show the startup display",
negative_help="Disable the startup display",
)
self.container.add_argument(
"-v",
"--verbosity",
action="count",
help="Control logging noise, eg. -vv or --verbosity=2 [default 0]",
)
self.add_bool_arguments(
"--noisy-exceptions",
dest="noisy_exceptions",
help="Output stack traces for all exceptions",
default=None,
)
class DaemonGroup(Group):
name = "Daemon"
def attach(self, short: bool = False):
if OS_IS_WINDOWS:
return
self.container.add_argument(
"-D",
"--daemon",
dest="daemon",
action="store_true",
help="Run server in background (auto-generated PID file)",
)
if short:
return
self.container.add_argument(
"--pidfile",
dest="pidfile",
type=str,
default=None,
help="Override auto-generated PID file path (requires --daemon)",
)
self.container.add_argument(
"--logfile",
dest="logfile",
type=str,
default=None,
help="Path to log file for daemon output (requires --daemon)",
)
self.container.add_argument(
"--user",
dest="daemon_user",
type=str,
default=None,
help="User to run daemon as (requires root)",
)
self.container.add_argument(
"--group",
dest="daemon_group",
type=str,
default=None,
help="Group to run daemon as (requires root)",
)
def prepare(self, args):
if OS_IS_WINDOWS:
return
has_daemon_opts = getattr(args, "pidfile", None) or getattr(
args, "logfile", None
)
if has_daemon_opts and not getattr(args, "daemon", False):
raise SystemExit("Error: --pidfile and --logfile require --daemon")
@@ -0,0 +1,35 @@
from argparse import (
SUPPRESS,
Action,
ArgumentParser,
RawTextHelpFormatter,
_SubParsersAction,
)
from typing import Any
class SanicArgumentParser(ArgumentParser):
def _check_value(self, action: Action, value: Any) -> None:
if isinstance(action, SanicSubParsersAction):
return
super()._check_value(action, value)
class SanicHelpFormatter(RawTextHelpFormatter):
def add_usage(self, usage, actions, groups, prefix=None):
if not usage:
usage = SUPPRESS
# Add one linebreak, but not two
self.add_text("\x1b[1A")
super().add_usage(usage, actions, groups, prefix)
class SanicSubParsersAction(_SubParsersAction):
def __call__(self, parser, namespace, values, option_string=None):
self._name_parser_map
parser_name = values[0]
if parser_name not in self._name_parser_map:
self._name_parser_map[parser_name] = parser
values = ["<custom>", *values]
super().__call__(parser, namespace, values, option_string)
@@ -0,0 +1,309 @@
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()
@@ -0,0 +1,164 @@
from __future__ import annotations
import os
import signal
import sys
from argparse import ArgumentParser
from contextlib import suppress
from pathlib import Path
from sanic.worker.daemon import Daemon
def _add_target_args(parser: ArgumentParser) -> None:
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--pid",
type=int,
metavar="PID",
help="Process ID of the daemon",
)
group.add_argument(
"--pidfile",
type=str,
metavar="PATH",
help="Path to PID file of the daemon",
)
def make_kill_parser(parser: ArgumentParser) -> None:
"""Kill command always sends SIGKILL."""
_add_target_args(parser)
def make_status_parser(parser: ArgumentParser) -> None:
_add_target_args(parser)
def make_restart_parser(parser: ArgumentParser) -> None:
_add_target_args(parser)
def resolve_target(
pid: int | None, pidfile: str | None
) -> tuple[int, Path | None]:
"""
Resolve a PID from either a direct PID or a pidfile path.
Returns:
Tuple of (pid, pidfile_path or None)
Raises:
SystemExit: If pidfile not found or invalid
"""
if pid:
return pid, None
pidfile_path = Path(pidfile) # type: ignore[arg-type]
if not pidfile_path.exists():
print(f"PID file not found: {pidfile_path}", file=sys.stderr)
sys.exit(1)
resolved_pid = Daemon.read_pidfile(pidfile_path)
if resolved_pid is None:
print(f"Invalid PID file: {pidfile_path}", file=sys.stderr)
sys.exit(1)
return resolved_pid, pidfile_path
def _terminate_process(
pid: int, sig: signal.Signals, pidfile: Path | None = None
) -> None:
"""Send a signal to terminate a process and clean up pidfile."""
sig_name = sig.name
try:
os.kill(pid, sig)
print(f"Sent {sig_name} to process {pid}")
except ProcessLookupError:
print(f"Process {pid} not found", file=sys.stderr)
sys.exit(1)
except PermissionError:
print(
f"Permission denied to signal process {pid}. "
"Are you running as the correct user?",
file=sys.stderr,
)
sys.exit(1)
except OSError as e:
print(f"Failed to signal process {pid}: {e}", file=sys.stderr)
sys.exit(1)
if pidfile:
if pidfile.exists():
try:
pidfile.unlink()
print(f"Removed PID file: {pidfile}")
except OSError as e:
print(
f"Warning: Could not remove PID file {pidfile}: {e}",
file=sys.stderr,
)
lockfile = pidfile.with_suffix(".lock")
if lockfile.exists():
try:
lockfile.unlink()
except OSError:
pass
def kill_daemon(pid: int, pidfile: Path | None = None) -> None:
"""Force kill a daemon process with SIGKILL."""
_terminate_process(pid, signal.SIGKILL, pidfile)
def stop_daemon(
pid: int, pidfile: Path | None = None, force: bool = False
) -> None:
"""Stop a daemon process gracefully (SIGTERM) or forcefully (SIGKILL)."""
sig = signal.SIGKILL if force else signal.SIGTERM
_terminate_process(pid, sig, pidfile)
def status_daemon(pid: int, pidfile: Path | None = None) -> bool:
"""
Check if a daemon process is running.
Args:
pid: Process ID to check
pidfile: Optional pidfile path to clean up if stale
Returns:
True if running, False otherwise (exits with code 1 if not)
"""
try:
os.kill(pid, 0)
running = True
except ProcessLookupError:
running = False
except PermissionError:
running = True
if running:
print(f"Process {pid} is running")
return True
print(f"Process {pid} is NOT running")
if pidfile and pidfile.exists():
with suppress(OSError):
pidfile.unlink()
print(f"Removed stale PID file: {pidfile}")
sys.exit(1)
def restart_daemon(pid: int) -> None:
"""
Restart a daemon process.
Args:
pid: Process ID to restart (unused, for future use)
"""
print("Restart is not yet implemented. Coming in a future release.")
sys.exit(0)
@@ -0,0 +1,100 @@
import shutil
from argparse import ArgumentParser
from asyncio import run
from inspect import signature
from typing import Callable
from sanic import Sanic
from sanic.application.logo import get_logo
from sanic.cli.base import (
SanicArgumentParser,
SanicHelpFormatter,
)
def make_executor_parser(parser: ArgumentParser) -> None:
parser.add_argument(
"command",
help="Command to execute",
)
class ExecutorSubParser(ArgumentParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.description:
self.description = ""
self.description = get_logo(True) + self.description
class Executor:
def __init__(self, app: Sanic, kwargs: dict) -> None:
self.app = app
self.kwargs = kwargs
self.commands = self._make_commands()
self.parser = self._make_parser()
def run(self, command: str, args: list[str]) -> None:
if command == "exec":
args = ["--help"]
parsed_args = self.parser.parse_args(args)
if command not in self.commands:
raise ValueError(f"Unknown command: {command}")
parsed_kwargs = vars(parsed_args)
parsed_kwargs.pop("command")
run(self.commands[command](**parsed_kwargs))
def _make_commands(self) -> dict[str, Callable]:
commands = {c.name: c.func for c in self.app._future_commands}
return commands
def _make_parser(self) -> SanicArgumentParser:
width = shutil.get_terminal_size().columns
parser = SanicArgumentParser(
prog="sanic",
description=get_logo(True),
formatter_class=lambda prog: SanicHelpFormatter(
prog,
max_help_position=36 if width > 96 else 24,
indent_increment=4,
width=None,
),
)
subparsers = parser.add_subparsers(
dest="command",
title=" Commands",
parser_class=ExecutorSubParser,
)
for command in self.app._future_commands:
sub = subparsers.add_parser(
command.name,
help=command.func.__doc__ or f"Execute {command.name}",
formatter_class=SanicHelpFormatter,
)
self._add_arguments(sub, command.func)
return parser
def _add_arguments(self, parser: ArgumentParser, func: Callable) -> None:
sig = signature(func)
for param in sig.parameters.values():
kwargs = {}
if param.default is not param.empty:
kwargs["default"] = param.default
# In Python 3.14+, argparse validates help strings and rejects
# non-string types. Convert annotations to string representation.
help_text = None
if param.annotation is not param.empty:
if isinstance(param.annotation, str):
help_text = param.annotation
else:
help_text = getattr(
param.annotation, "__name__", str(param.annotation)
)
parser.add_argument(
f"--{param.name}",
help=help_text,
**kwargs,
)
@@ -0,0 +1,105 @@
from argparse import ArgumentParser
from sanic.application.logo import get_logo
from sanic.cli.base import SanicHelpFormatter, SanicSubParsersAction
def _add_shared(parser: ArgumentParser) -> None:
parser.add_argument(
"--host",
"-H",
default="localhost",
help="Inspector host address [default 127.0.0.1]",
)
parser.add_argument(
"--port",
"-p",
default=6457,
type=int,
help="Inspector port [default 6457]",
)
parser.add_argument(
"--secure",
"-s",
action="store_true",
help="Whether to access the Inspector via TLS encryption",
)
parser.add_argument("--api-key", "-k", help="Inspector authentication key")
parser.add_argument(
"--raw",
action="store_true",
help="Whether to output the raw response information",
)
class InspectorSubParser(ArgumentParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
_add_shared(self)
if not self.description:
self.description = ""
self.description = get_logo(True) + self.description
def make_inspector_parser(parser: ArgumentParser) -> None:
_add_shared(parser)
subparsers = parser.add_subparsers(
action=SanicSubParsersAction,
dest="action",
description=(
"Run one or none of the below subcommands. Using inspect without "
"a subcommand will fetch general information about the state "
"of the application instance.\n\n"
"Or, you can optionally follow inspect with a subcommand. "
"If you have created a custom "
"Inspector instance, then you can run custom commands. See "
"https://sanic.dev/en/guide/deployment/inspector.html "
"for more details."
),
title=" Subcommands",
parser_class=InspectorSubParser,
)
reloader = subparsers.add_parser(
"reload",
help="Trigger a reload of the server workers",
formatter_class=SanicHelpFormatter,
)
reloader.add_argument(
"--zero-downtime",
action="store_true",
help=(
"Whether to wait for the new process to be online before "
"terminating the old"
),
)
subparsers.add_parser(
"shutdown",
help="Shutdown the application and all processes",
formatter_class=SanicHelpFormatter,
)
scale = subparsers.add_parser(
"scale",
help="Scale the number of workers",
formatter_class=SanicHelpFormatter,
)
scale.add_argument(
"replicas",
type=int,
help="Number of workers requested",
)
custom = subparsers.add_parser(
"<custom>",
help="Run a custom command",
description=(
"keyword arguments:\n When running a custom command, you can "
"add keyword arguments by appending them to your command\n\n"
"\tsanic inspect foo --one=1 --two=2"
),
formatter_class=SanicHelpFormatter,
)
custom.add_argument(
"positional",
nargs="*",
help="Add one or more non-keyword args to your custom command",
)
@@ -0,0 +1,119 @@
from __future__ import annotations
import sys
from http.client import RemoteDisconnected
from textwrap import indent
from typing import Any
from urllib.error import URLError
from urllib.request import Request as URequest
from urllib.request import urlopen
from sanic.application.logo import get_logo
from sanic.application.motd import MOTDTTY
from sanic.log import Colors
try: # no cov
from ujson import dumps, loads
except ModuleNotFoundError: # no cov
from json import dumps, loads # type: ignore
class InspectorClient:
def __init__(
self,
host: str,
port: int,
secure: bool,
raw: bool,
api_key: str | None,
) -> None:
self.scheme = "https" if secure else "http"
self.host = host
self.port = port
self.raw = raw
self.api_key = api_key
for scheme in ("http", "https"):
full = f"{scheme}://"
if self.host.startswith(full):
self.scheme = scheme
self.host = self.host[len(full) :] # noqa E203
def do(self, action: str, **kwargs: Any) -> None:
if action == "info":
self.info()
return
result = self.request(action, **kwargs).get("result")
if result:
out = (
dumps(result)
if isinstance(result, (list, dict))
else str(result)
)
sys.stdout.write(out + "\n")
def info(self) -> None:
out = sys.stdout.write
response = self.request("", "GET")
if self.raw or not response:
return
data = response["result"]
display = data.pop("info")
extra = display.pop("extra", {})
display["packages"] = ", ".join(display["packages"])
MOTDTTY(get_logo(), self.base_url, display, extra).display(
version=False,
action="Inspecting",
out=out,
)
for name, info in data["workers"].items():
info = "\n".join(
f"\t{key}: {Colors.BLUE}{value}{Colors.END}"
for key, value in info.items()
)
out(
"\n"
+ indent(
"\n".join(
[
f"{Colors.BOLD}{Colors.SANIC}{name}{Colors.END}",
info,
]
),
" ",
)
+ "\n"
)
def request(self, action: str, method: str = "POST", **kwargs: Any) -> Any:
url = f"{self.base_url}/{action}"
params: dict[str, Any] = {"method": method, "headers": {}}
if kwargs:
params["data"] = dumps(kwargs).encode()
params["headers"]["content-type"] = "application/json"
if self.api_key:
params["headers"]["authorization"] = f"Bearer {self.api_key}"
request = URequest(url, **params)
try:
with urlopen(request) as response: # nosec B310
raw = response.read()
loaded = loads(raw)
if self.raw:
sys.stdout.write(dumps(loaded.get("result")) + "\n")
return {}
return loaded
except (URLError, RemoteDisconnected) as e:
sys.stderr.write(
f"{Colors.RED}Could not connect to inspector at: "
f"{Colors.YELLOW}{self.base_url}{Colors.END}\n"
"Either the application is not running, or it did not start "
f"an inspector instance.\n{e}\n"
)
sys.exit(1)
@property
def base_url(self):
return f"{self.scheme}://{self.host}:{self.port}"