Repository: sanic-org/sanic
Pull request #3019 (2024-12-31 11:54:03)

Custom commands
This adds the ability to add commands to the `sanic` CLI.

```python
@app.command
async def foo(one, two: str, three: str = "..."):
    logger.info(f"FOO {one=} {two=} {three=}")


@app.command
def bar():
    logger.info("BAR")


@app.command(name="qqq")
async def baz():
    logger.info("BAZ")
```

These are invoked using

```sh
sanic server:app exec <command> [--arg=value]
```


Patch:
diff --git a/sanic/app.py b/sanic/app.py
index b8b2792b..37dff776 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -74,6 +74,7 @@ from sanic.http import Stage
 from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
 from sanic.logging.setup import setup_logging
 from sanic.middleware import Middleware, MiddlewareLocation
+from sanic.mixins.commands import CommandMixin
 from sanic.mixins.listeners import ListenerEvent
 from sanic.mixins.startup import StartupMixin
 from sanic.mixins.static import StaticHandleMixin
@@ -119,6 +120,7 @@ class Sanic(
     StaticHandleMixin,
     BaseSanic,
     StartupMixin,
+    CommandMixin,
     metaclass=TouchUpMeta,
 ):
     """The main application instance
@@ -189,6 +191,7 @@ class Sanic(
         "_blueprint_order",
         "_delayed_tasks",
         "_ext",
+        "_future_commands",
         "_future_exceptions",
         "_future_listeners",
         "_future_middleware",
diff --git a/sanic/base/root.py b/sanic/base/root.py
index 3f3ba58e..e4f0ff91 100644
--- a/sanic/base/root.py
+++ b/sanic/base/root.py
@@ -4,6 +4,7 @@ from typing import Any, Optional
 
 from sanic.base.meta import SanicMeta
 from sanic.exceptions import SanicException
+from sanic.mixins.commands import CommandMixin
 from sanic.mixins.exceptions import ExceptionMixin
 from sanic.mixins.listeners import ListenerMixin
 from sanic.mixins.middleware import MiddlewareMixin
@@ -22,6 +23,7 @@ class BaseSanic(
     ListenerMixin,
     ExceptionMixin,
     SignalMixin,
+    CommandMixin,
     metaclass=SanicMeta,
 ):
     __slots__ = ("name",)
diff --git a/sanic/blueprints.py b/sanic/blueprints.py
index 1fdd67cf..fb247bed 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -97,6 +97,7 @@ class Blueprint(BaseSanic):
 
     __slots__ = (
         "_apps",
+        "_future_commands",
         "_future_routes",
         "_future_statics",
         "_future_middleware",
@@ -510,6 +511,11 @@ class Blueprint(BaseSanic):
                 ),
             )
 
+        if self._future_commands:
+            raise SanicException(
+                "Registering commands with blueprints is not supported."
+            )
+
     async def dispatch(self, *args, **kwargs):
         """Dispatch a signal event
 
diff --git a/sanic/cli/app.py b/sanic/cli/app.py
index 2a6a5639..fb2e06bf 100644
--- a/sanic/cli/app.py
+++ b/sanic/cli/app.py
@@ -12,6 +12,7 @@ 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.executor import Executor, make_executor_parser
 from sanic.cli.inspector import make_inspector_parser
 from sanic.cli.inspector_client import InspectorClient
 from sanic.helpers import _default, is_atty
@@ -64,11 +65,11 @@ Or, a path to a directory to run as a simple HTTP server:
         )
         self.args: Namespace = Namespace()
         self.groups: List[Group] = []
-        self.inspecting = False
+        self.run_mode = "serve"
 
     def attach(self):
         if len(sys.argv) > 1 and sys.argv[1] == "inspect":
-            self.inspecting = True
+            self.run_mode = "inspect"
             self.parser.description = get_logo(True)
             make_inspector_parser(self.parser)
             return
@@ -78,8 +79,13 @@ Or, a path to a directory to run as a simple HTTP server:
             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.inspecting:
+        if self.run_mode == "inspect":
             self._inspector()
             return
 
@@ -92,13 +98,22 @@ Or, a path to a directory to run as a simple HTTP server:
             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])
 
-        self.args = self.parser.parse_args(args=parse_args)
+        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
@@ -110,6 +125,12 @@ Or, a path to a directory to run as a simple HTTP server:
         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}")
+
             if self.args.repl:
                 self._repl(app)
             for http_version in self.args.http:
@@ -152,6 +173,10 @@ Or, a path to a directory to run as a simple HTTP server:
                 kwargs["args"] = positional[1:]
         InspectorClient(host, port, secure, raw, api_key).do(action, **kwargs)
 
+    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():
 
diff --git a/sanic/cli/arguments.py b/sanic/cli/arguments.py
index 6ac7cfce..ca495dac 100644
--- a/sanic/cli/arguments.py
+++ b/sanic/cli/arguments.py
@@ -68,6 +68,21 @@ class GeneralGroup(Group):
             ),
         )
 
+        self.container.add_argument(
+            "action",
+            nargs="?",
+            default="serve",
+            choices=[
+                "serve",
+                "exec",
+            ],
+            help=(
+                "Action to perform.\n"
+                "\tserve: Run the Sanic app\n"
+                "\texec: Execute a command in the Sanic app context\n"
+            ),
+        )
+
 
 class ApplicationGroup(Group):
     name = "Application"
diff --git a/sanic/cli/executor.py b/sanic/cli/executor.py
new file mode 100644
index 00000000..95096fda
--- /dev/null
+++ b/sanic/cli/executor.py
@@ -0,0 +1,90 @@
+import shutil
+
+from argparse import ArgumentParser
+from asyncio import run
+from inspect import signature
+from typing import Callable, Dict, List
+
+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
+            parser.add_argument(
+                f"--{param.name}",
+                help=param.annotation,
+                **kwargs,
+            )
diff --git a/sanic/mixins/commands.py b/sanic/mixins/commands.py
new file mode 100644
index 00000000..cf02ea35
--- /dev/null
+++ b/sanic/mixins/commands.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from functools import wraps
+from inspect import isawaitable
+from typing import Callable, Optional, Set, Union
+
+from sanic.base.meta import SanicMeta
+from sanic.models.futures import FutureCommand
+
+
+class CommandMixin(metaclass=SanicMeta):
+    def __init__(self, *args, **kwargs) -> None:
+        self._future_commands: Set[FutureCommand] = set()
+
+    def command(
+        self, maybe_func: Optional[Callable] = None, *, name: str = ""
+    ) -> Union[Callable, Callable[[Callable], Callable]]:
+        def decorator(f):
+            @wraps(f)
+            async def decorated_function(*args, **kwargs):
+                response = f(*args, **kwargs)
+                if isawaitable(response):
+                    response = await response
+                return response
+
+            self._future_commands.add(
+                FutureCommand(name or f.__name__, decorated_function)
+            )
+            return decorated_function
+
+        return decorator(maybe_func) if maybe_func else decorator
diff --git a/sanic/mixins/static.py b/sanic/mixins/static.py
index b1aa126c..6c2d7945 100644
--- a/sanic/mixins/static.py
+++ b/sanic/mixins/static.py
@@ -179,15 +179,16 @@ class StaticHandleMixin(metaclass=SanicMeta):
         Register a static directory handler with Sanic by adding a route to the
         router and registering a handler.
         """
+        file_or_directory: PathLike
 
         if isinstance(static.file_or_directory, bytes):
-            file_or_directory = static.file_or_directory.decode("utf-8")
+            file_or_directory = Path(static.file_or_directory.decode("utf-8"))
         elif isinstance(static.file_or_directory, PurePath):
-            file_or_directory = str(static.file_or_directory)
-        elif not isinstance(static.file_or_directory, str):
-            raise ValueError("Invalid file path string.")
-        else:
             file_or_directory = static.file_or_directory
+        elif isinstance(static.file_or_directory, str):
+            file_or_directory = Path(static.file_or_directory)
+        else:
+            raise ValueError("Invalid file path string.")
 
         uri = static.uri
         name = static.name
@@ -224,7 +225,7 @@ class StaticHandleMixin(metaclass=SanicMeta):
         _handler = wraps(self._static_request_handler)(
             partial(
                 self._static_request_handler,
-                file_or_directory=file_or_directory,
+                file_or_directory=str(file_or_directory),
                 use_modified_since=static.use_modified_since,
                 use_content_range=static.use_content_range,
                 stream_large_files=static.stream_large_files,
diff --git a/sanic/models/futures.py b/sanic/models/futures.py
index 84e7f6e6..23fce578 100644
--- a/sanic/models/futures.py
+++ b/sanic/models/futures.py
@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Dict, Iterable, List, NamedTuple, Optional, Union
+from typing import Callable, Dict, Iterable, List, NamedTuple, Optional, Union
 
 from sanic.handlers.directory import DirectoryHandler
 from sanic.models.handler_types import (
@@ -70,3 +70,8 @@ class FutureSignal(NamedTuple):
 
 
 class FutureRegistry(set): ...
+
+
+class FutureCommand(NamedTuple):
+    name: str
+    func: Callable


Test patch:
diff --git a/tests/fake/server.py b/tests/fake/server.py
index 577b70bf..8fdf18bb 100644
--- a/tests/fake/server.py
+++ b/tests/fake/server.py
@@ -52,3 +52,18 @@ def create_app_with_args(args):
         logger.info(f"target={args.target}")
 
     return app
+
+
+@app.command
+async def foo(one, two: str, three: str = "..."):
+    logger.info(f"FOO {one=} {two=} {three=}")
+
+
+@app.command
+def bar():
+    logger.info("BAR")
+
+
+@app.command(name="qqq")
+async def baz():
+    logger.info("BAZ")
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 949f012d..670a7bc7 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -369,3 +369,38 @@ def test_server_run_with_repl(caplog, capsys):
 
     run()
     assert record in caplog.record_tuples
+
+
+def test_command_no_args(caplog):
+    args = ["fake.server.app", "exec", "foo"]
+    with patch("sys.argv", ["sanic", *args]):
+        lines = capture(args, caplog)
+    assert "FOO one=None two=None three='...'" in lines
+
+
+def test_command_with_args(caplog):
+    args = [
+        "fake.server.app",
+        "exec",
+        "foo",
+        "--one=1",
+        "--two=2",
+        "--three=3",
+    ]
+    with patch("sys.argv", ["sanic", *args]):
+        lines = capture(args, caplog)
+    assert "FOO one='1' two='2' three='3'" in lines
+
+
+def test_command_with_sync_handler(caplog):
+    args = ["fake.server.app", "exec", "bar"]
+    with patch("sys.argv", ["sanic", *args]):
+        lines = capture(args, caplog)
+    assert "BAR" in lines
+
+
+def test_command_with_renamed_command(caplog):
+    args = ["fake.server.app", "exec", "qqq"]
+    with patch("sys.argv", ["sanic", *args]):
+        lines = capture(args, caplog)
+    assert "BAZ" in lines