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:
@@ -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}"
|
||||
Reference in New Issue
Block a user