Skip to content

Commit 4327b8b

Browse files
authored
Custom commands (#3019)
* Cleanup some typing * Add custom CLI commands * Add unit tests * Add exception to commands on BPs * Add type force to str
1 parent 831c64f commit 4327b8b

File tree

11 files changed

+239
-11
lines changed

11 files changed

+239
-11
lines changed

sanic/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
7575
from sanic.logging.setup import setup_logging
7676
from sanic.middleware import Middleware, MiddlewareLocation
77+
from sanic.mixins.commands import CommandMixin
7778
from sanic.mixins.listeners import ListenerEvent
7879
from sanic.mixins.startup import StartupMixin
7980
from sanic.mixins.static import StaticHandleMixin
@@ -119,6 +120,7 @@ class Sanic(
119120
StaticHandleMixin,
120121
BaseSanic,
121122
StartupMixin,
123+
CommandMixin,
122124
metaclass=TouchUpMeta,
123125
):
124126
"""The main application instance
@@ -189,6 +191,7 @@ class to use for the application. Defaults to `None`.
189191
"_blueprint_order",
190192
"_delayed_tasks",
191193
"_ext",
194+
"_future_commands",
192195
"_future_exceptions",
193196
"_future_listeners",
194197
"_future_middleware",

sanic/base/root.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from sanic.base.meta import SanicMeta
66
from sanic.exceptions import SanicException
7+
from sanic.mixins.commands import CommandMixin
78
from sanic.mixins.exceptions import ExceptionMixin
89
from sanic.mixins.listeners import ListenerMixin
910
from sanic.mixins.middleware import MiddlewareMixin
@@ -22,6 +23,7 @@ class BaseSanic(
2223
ListenerMixin,
2324
ExceptionMixin,
2425
SignalMixin,
26+
CommandMixin,
2527
metaclass=SanicMeta,
2628
):
2729
__slots__ = ("name",)

sanic/blueprints.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class Blueprint(BaseSanic):
9797

9898
__slots__ = (
9999
"_apps",
100+
"_future_commands",
100101
"_future_routes",
101102
"_future_statics",
102103
"_future_middleware",
@@ -510,6 +511,11 @@ def register(self, app, options):
510511
),
511512
)
512513

514+
if self._future_commands:
515+
raise SanicException(
516+
"Registering commands with blueprints is not supported."
517+
)
518+
513519
async def dispatch(self, *args, **kwargs):
514520
"""Dispatch a signal event
515521

sanic/cli/app.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sanic.cli.arguments import Group
1313
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
1414
from sanic.cli.console import SanicREPL
15+
from sanic.cli.executor import Executor, make_executor_parser
1516
from sanic.cli.inspector import make_inspector_parser
1617
from sanic.cli.inspector_client import InspectorClient
1718
from sanic.helpers import _default, is_atty
@@ -64,11 +65,11 @@ def __init__(self) -> None:
6465
)
6566
self.args: Namespace = Namespace()
6667
self.groups: List[Group] = []
67-
self.inspecting = False
68+
self.run_mode = "serve"
6869

6970
def attach(self):
7071
if len(sys.argv) > 1 and sys.argv[1] == "inspect":
71-
self.inspecting = True
72+
self.run_mode = "inspect"
7273
self.parser.description = get_logo(True)
7374
make_inspector_parser(self.parser)
7475
return
@@ -78,8 +79,13 @@ def attach(self):
7879
instance.attach()
7980
self.groups.append(instance)
8081

82+
if len(sys.argv) > 2 and sys.argv[2] == "exec":
83+
self.run_mode = "exec"
84+
self.parser.description = get_logo(True)
85+
make_executor_parser(self.parser)
86+
8187
def run(self, parse_args=None):
82-
if self.inspecting:
88+
if self.run_mode == "inspect":
8389
self._inspector()
8490
return
8591

@@ -92,13 +98,22 @@ def run(self, parse_args=None):
9298
parse_args = ["--version"]
9399

94100
if not legacy_version:
101+
if self.run_mode == "exec":
102+
parse_args = [
103+
a
104+
for a in (parse_args or sys.argv[1:])
105+
if a not in "-h --help".split()
106+
]
95107
parsed, unknown = self.parser.parse_known_args(args=parse_args)
96108
if unknown and parsed.factory:
97109
for arg in unknown:
98110
if arg.startswith("--"):
99111
self.parser.add_argument(arg.split("=")[0])
100112

101-
self.args = self.parser.parse_args(args=parse_args)
113+
if self.run_mode == "exec":
114+
self.args, _ = self.parser.parse_known_args(args=parse_args)
115+
else:
116+
self.args = self.parser.parse_args(args=parse_args)
102117
self._precheck()
103118
app_loader = AppLoader(
104119
self.args.target, self.args.factory, self.args.simple, self.args
@@ -110,6 +125,12 @@ def run(self, parse_args=None):
110125
except ValueError as e:
111126
error_logger.exception(f"Failed to run app: {e}")
112127
else:
128+
if self.run_mode == "exec":
129+
self._executor(app, kwargs)
130+
return
131+
elif self.run_mode != "serve":
132+
raise ValueError(f"Unknown run mode: {self.run_mode}")
133+
113134
if self.args.repl:
114135
self._repl(app)
115136
for http_version in self.args.http:
@@ -152,6 +173,10 @@ def _inspector(self):
152173
kwargs["args"] = positional[1:]
153174
InspectorClient(host, port, secure, raw, api_key).do(action, **kwargs)
154175

176+
def _executor(self, app: Sanic, kwargs: dict):
177+
args = sys.argv[3:]
178+
Executor(app, kwargs).run(self.args.command, args)
179+
155180
def _repl(self, app: Sanic):
156181
if is_atty():
157182

sanic/cli/arguments.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,21 @@ def attach(self):
6868
),
6969
)
7070

71+
self.container.add_argument(
72+
"action",
73+
nargs="?",
74+
default="serve",
75+
choices=[
76+
"serve",
77+
"exec",
78+
],
79+
help=(
80+
"Action to perform.\n"
81+
"\tserve: Run the Sanic app\n"
82+
"\texec: Execute a command in the Sanic app context\n"
83+
),
84+
)
85+
7186

7287
class ApplicationGroup(Group):
7388
name = "Application"

sanic/cli/executor.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import shutil
2+
3+
from argparse import ArgumentParser
4+
from asyncio import run
5+
from inspect import signature
6+
from typing import Callable, Dict, List
7+
8+
from sanic import Sanic
9+
from sanic.application.logo import get_logo
10+
from sanic.cli.base import (
11+
SanicArgumentParser,
12+
SanicHelpFormatter,
13+
)
14+
15+
16+
def make_executor_parser(parser: ArgumentParser) -> None:
17+
parser.add_argument(
18+
"command",
19+
help="Command to execute",
20+
)
21+
22+
23+
class ExecutorSubParser(ArgumentParser):
24+
def __init__(self, *args, **kwargs):
25+
super().__init__(*args, **kwargs)
26+
if not self.description:
27+
self.description = ""
28+
self.description = get_logo(True) + self.description
29+
30+
31+
class Executor:
32+
def __init__(self, app: Sanic, kwargs: dict) -> None:
33+
self.app = app
34+
self.kwargs = kwargs
35+
self.commands = self._make_commands()
36+
self.parser = self._make_parser()
37+
38+
def run(self, command: str, args: List[str]) -> None:
39+
if command == "exec":
40+
args = ["--help"]
41+
parsed_args = self.parser.parse_args(args)
42+
if command not in self.commands:
43+
raise ValueError(f"Unknown command: {command}")
44+
parsed_kwargs = vars(parsed_args)
45+
parsed_kwargs.pop("command")
46+
run(self.commands[command](**parsed_kwargs))
47+
48+
def _make_commands(self) -> Dict[str, Callable]:
49+
commands = {c.name: c.func for c in self.app._future_commands}
50+
return commands
51+
52+
def _make_parser(self) -> SanicArgumentParser:
53+
width = shutil.get_terminal_size().columns
54+
parser = SanicArgumentParser(
55+
prog="sanic",
56+
description=get_logo(True),
57+
formatter_class=lambda prog: SanicHelpFormatter(
58+
prog,
59+
max_help_position=36 if width > 96 else 24,
60+
indent_increment=4,
61+
width=None,
62+
),
63+
)
64+
65+
subparsers = parser.add_subparsers(
66+
dest="command",
67+
title=" Commands",
68+
parser_class=ExecutorSubParser,
69+
)
70+
for command in self.app._future_commands:
71+
sub = subparsers.add_parser(
72+
command.name,
73+
help=command.func.__doc__ or f"Execute {command.name}",
74+
formatter_class=SanicHelpFormatter,
75+
)
76+
self._add_arguments(sub, command.func)
77+
78+
return parser
79+
80+
def _add_arguments(self, parser: ArgumentParser, func: Callable) -> None:
81+
sig = signature(func)
82+
for param in sig.parameters.values():
83+
kwargs = {}
84+
if param.default is not param.empty:
85+
kwargs["default"] = param.default
86+
parser.add_argument(
87+
f"--{param.name}",
88+
help=param.annotation,
89+
**kwargs,
90+
)

sanic/mixins/commands.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from functools import wraps
4+
from inspect import isawaitable
5+
from typing import Callable, Optional, Set, Union
6+
7+
from sanic.base.meta import SanicMeta
8+
from sanic.models.futures import FutureCommand
9+
10+
11+
class CommandMixin(metaclass=SanicMeta):
12+
def __init__(self, *args, **kwargs) -> None:
13+
self._future_commands: Set[FutureCommand] = set()
14+
15+
def command(
16+
self, maybe_func: Optional[Callable] = None, *, name: str = ""
17+
) -> Union[Callable, Callable[[Callable], Callable]]:
18+
def decorator(f):
19+
@wraps(f)
20+
async def decorated_function(*args, **kwargs):
21+
response = f(*args, **kwargs)
22+
if isawaitable(response):
23+
response = await response
24+
return response
25+
26+
self._future_commands.add(
27+
FutureCommand(name or f.__name__, decorated_function)
28+
)
29+
return decorated_function
30+
31+
return decorator(maybe_func) if maybe_func else decorator

sanic/mixins/static.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,16 @@ def _register_static(
179179
Register a static directory handler with Sanic by adding a route to the
180180
router and registering a handler.
181181
"""
182+
file_or_directory: PathLike
182183

183184
if isinstance(static.file_or_directory, bytes):
184-
file_or_directory = static.file_or_directory.decode("utf-8")
185+
file_or_directory = Path(static.file_or_directory.decode("utf-8"))
185186
elif isinstance(static.file_or_directory, PurePath):
186-
file_or_directory = str(static.file_or_directory)
187-
elif not isinstance(static.file_or_directory, str):
188-
raise ValueError("Invalid file path string.")
189-
else:
190187
file_or_directory = static.file_or_directory
188+
elif isinstance(static.file_or_directory, str):
189+
file_or_directory = Path(static.file_or_directory)
190+
else:
191+
raise ValueError("Invalid file path string.")
191192

192193
uri = static.uri
193194
name = static.name
@@ -224,7 +225,7 @@ def _register_static(
224225
_handler = wraps(self._static_request_handler)(
225226
partial(
226227
self._static_request_handler,
227-
file_or_directory=file_or_directory,
228+
file_or_directory=str(file_or_directory),
228229
use_modified_since=static.use_modified_since,
229230
use_content_range=static.use_content_range,
230231
stream_large_files=static.stream_large_files,

sanic/models/futures.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from typing import Dict, Iterable, List, NamedTuple, Optional, Union
2+
from typing import Callable, Dict, Iterable, List, NamedTuple, Optional, Union
33

44
from sanic.handlers.directory import DirectoryHandler
55
from sanic.models.handler_types import (
@@ -70,3 +70,8 @@ class FutureSignal(NamedTuple):
7070

7171

7272
class FutureRegistry(set): ...
73+
74+
75+
class FutureCommand(NamedTuple):
76+
name: str
77+
func: Callable

tests/fake/server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,18 @@ def create_app_with_args(args):
5252
logger.info(f"target={args.target}")
5353

5454
return app
55+
56+
57+
@app.command
58+
async def foo(one, two: str, three: str = "..."):
59+
logger.info(f"FOO {one=} {two=} {three=}")
60+
61+
62+
@app.command
63+
def bar():
64+
logger.info("BAR")
65+
66+
67+
@app.command(name="qqq")
68+
async def baz():
69+
logger.info("BAZ")

0 commit comments

Comments
 (0)