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>
500 lines
16 KiB
Python
500 lines
16 KiB
Python
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
|