diff --git a/sanic/__main__.py b/sanic/__main__.py index 43836bfb..53ef5e64 100644 --- a/sanic/__main__.py +++ b/sanic/__main__.py @@ -5,6 +5,8 @@ from argparse import ArgumentParser, RawDescriptionHelpFormatter from importlib import import_module from typing import Any, Dict, Optional +from sanic_routing import __version__ as __routing_version__ # type: ignore + from sanic import __version__ from sanic.app import Sanic from sanic.config import BASE_LOGO @@ -65,15 +67,22 @@ def main(): default=1, help="number of worker processes [default 1]", ) - parser.add_argument("--debug", dest="debug", action="store_true") - parser.add_bool_arguments( - "--access-logs", dest="access_log", help="display access logs" + parser.add_argument("-d", "--debug", dest="debug", action="store_true") + parser.add_argument( + "-r", + "--auto-reload", + dest="auto_reload", + action="store_true", + help="Watch source directory for file changes and reload on changes", ) parser.add_argument( "-v", "--version", action="version", - version=f"Sanic {__version__}", + version=f"Sanic {__version__}; Routing {__routing_version__}", + ) + parser.add_bool_arguments( + "--access-logs", dest="access_log", help="display access logs" ) parser.add_argument( "module", help="path to your Sanic app. Example: path.to.server:app" @@ -85,12 +94,8 @@ def main(): if module_path not in sys.path: sys.path.append(module_path) - if ":" in args.module: - module_name, app_name = args.module.rsplit(":", 1) - else: - module_parts = args.module.split(".") - module_name = ".".join(module_parts[:-1]) - app_name = module_parts[-1] + delimiter = ":" if ":" in args.module else "." + module_name, app_name = args.module.rsplit(delimiter, 1) module = import_module(module_name) app = getattr(module, app_name, None) @@ -102,28 +107,34 @@ def main(): f"Perhaps you meant {args.module}.app?" ) if args.cert is not None or args.key is not None: - ssl = { + ssl: Optional[Dict[str, Any]] = { "cert": args.cert, "key": args.key, - } # type: Optional[Dict[str, Any]] + } else: ssl = None - app.run( - host=args.host, - port=args.port, - unix=args.unix, - workers=args.workers, - debug=args.debug, - access_log=args.access_log, - ssl=ssl, - ) + kwargs = { + "host": args.host, + "port": args.port, + "unix": args.unix, + "workers": args.workers, + "debug": args.debug, + "access_log": args.access_log, + "ssl": ssl, + } + if args.auto_reload: + kwargs["auto_reload"] = True + app.run(**kwargs) except ImportError as e: - error_logger.error( - f"No module named {e.name} found.\n" - f" Example File: project/sanic_server.py -> app\n" - f" Example Module: project.sanic_server.app" - ) + if module_name.startswith(e.name): + 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" + ) + else: + raise e except ValueError: error_logger.exception("Failed to run app") diff --git a/sanic/app.py b/sanic/app.py index 5d97c9ce..cb765b6a 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -90,6 +90,7 @@ class Sanic(BaseSanic): "_future_signals", "_test_client", "_test_manager", + "auto_reload", "asgi", "blueprints", "config", @@ -150,6 +151,7 @@ class Sanic(BaseSanic): self._test_client = None self._test_manager = None self.asgi = False + self.auto_reload = False self.blueprints: Dict[str, Blueprint] = {} self.config = Config(load_env=load_env, env_prefix=env_prefix) self.configure_logging = configure_logging @@ -876,8 +878,9 @@ class Sanic(BaseSanic): ) if auto_reload or auto_reload is None and debug: + self.auto_reload = True if os.environ.get("SANIC_SERVER_RUNNING") != "true": - return reloader_helpers.watchdog(1.0) + return reloader_helpers.watchdog(1.0, self) if sock is None: host, port = host or "127.0.0.1", port or 8000 @@ -1176,6 +1179,10 @@ class Sanic(BaseSanic): else: logger.info(f"Goin' Fast @ {proto}://{host}:{port}") + debug_mode = "enabled" if self.debug else "disabled" + logger.debug("Sanic auto-reload: enabled") + logger.debug(f"Sanic debug mode: {debug_mode}") + return server_settings def _build_endpoint_name(self, *parts): diff --git a/sanic/reloader_helpers.py b/sanic/reloader_helpers.py index 78750cba..c09697f2 100644 --- a/sanic/reloader_helpers.py +++ b/sanic/reloader_helpers.py @@ -5,6 +5,9 @@ import sys from time import sleep +from sanic.config import BASE_LOGO +from sanic.log import logger + def _iter_module_files(): """This iterates over all relevant Python files. @@ -56,7 +59,7 @@ def restart_with_reloader(): ) -def watchdog(sleep_interval): +def watchdog(sleep_interval, app): """Watch project files, restart worker process if a change happened. :param sleep_interval: interval in second. @@ -73,6 +76,11 @@ def watchdog(sleep_interval): worker_process = restart_with_reloader() + if app.config.LOGO: + logger.debug( + app.config.LOGO if isinstance(app.config.LOGO, str) else BASE_LOGO + ) + try: while True: need_reload = False diff --git a/tests/fake/server.py b/tests/fake/server.py new file mode 100644 index 00000000..520f5123 --- /dev/null +++ b/tests/fake/server.py @@ -0,0 +1,32 @@ +import json +import logging + +from sanic import Sanic, text +from sanic.log import LOGGING_CONFIG_DEFAULTS, logger + + +LOGGING_CONFIG = {**LOGGING_CONFIG_DEFAULTS} +LOGGING_CONFIG["formatters"]["generic"]["format"] = "%(message)s" +LOGGING_CONFIG["loggers"]["sanic.root"]["level"] = "DEBUG" + +app = Sanic(__name__, log_config=LOGGING_CONFIG) + + +@app.get("/") +async def handler(request): + return text(request.ip) + + +@app.before_server_start +async def app_info_dump(app: Sanic, _): + app_data = { + "access_log": app.config.ACCESS_LOG, + "auto_reload": app.auto_reload, + "debug": app.debug, + } + logger.info(json.dumps(app_data)) + + +@app.after_server_start +async def shutdown(app: Sanic, _): + app.stop() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..3ff4d333 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,125 @@ +import json +import subprocess + +from pathlib import Path + +import pytest + +from sanic_routing import __version__ as __routing_version__ + +from sanic import __version__ +from sanic.config import BASE_LOGO + + +def capture(command): + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=Path(__file__).parent, + ) + try: + out, err = proc.communicate(timeout=0.5) + except subprocess.TimeoutExpired: + proc.kill() + out, err = proc.communicate() + return out, err, proc.returncode + + +@pytest.mark.parametrize("appname", ("fake.server.app", "fake.server:app")) +def test_server_run(appname): + command = ["sanic", appname] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + firstline = lines[6] + + assert exitcode != 1 + assert firstline == b"Goin' Fast @ http://127.0.0.1:8000" + + +@pytest.mark.parametrize( + "cmd", + ( + ("--host=localhost", "--port=9999"), + ("-H", "localhost", "-p", "9999"), + ), +) +def test_host_port(cmd): + command = ["sanic", "fake.server.app", *cmd] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + firstline = lines[6] + + assert exitcode != 1 + assert firstline == b"Goin' Fast @ http://localhost:9999" + + +@pytest.mark.parametrize( + "num,cmd", + ( + (1, (f"--workers={1}",)), + (2, (f"--workers={2}",)), + (4, (f"--workers={4}",)), + (1, ("-w", "1")), + (2, ("-w", "2")), + (4, ("-w", "4")), + ), +) +def test_num_workers(num, cmd): + command = ["sanic", "fake.server.app", *cmd] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + + worker_lines = [line for line in lines if b"worker" in line] + assert exitcode != 1 + assert len(worker_lines) == num * 2 + + +@pytest.mark.parametrize("cmd", ("--debug", "-d")) +def test_debug(cmd): + command = ["sanic", "fake.server.app", cmd] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + + app_info = lines[9] + info = json.loads(app_info) + + assert (b"\n".join(lines[:6])).decode("utf-8") == BASE_LOGO + assert info["debug"] is True + assert info["auto_reload"] is True + + +@pytest.mark.parametrize("cmd", ("--auto-reload", "-r")) +def test_auto_reload(cmd): + command = ["sanic", "fake.server.app", cmd] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + + app_info = lines[9] + info = json.loads(app_info) + + assert info["debug"] is False + assert info["auto_reload"] is True + + +@pytest.mark.parametrize( + "cmd,expected", (("--access-log", True), ("--no-access-log", False)) +) +def test_access_logs(cmd, expected): + command = ["sanic", "fake.server.app", cmd] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + + app_info = lines[9] + info = json.loads(app_info) + + assert info["access_log"] is expected + + +@pytest.mark.parametrize("cmd", ("--version", "-v")) +def test_version(cmd): + command = ["sanic", cmd] + out, err, exitcode = capture(command) + version_string = f"Sanic {__version__}; Routing {__routing_version__}\n" + + assert out == version_string.encode("utf-8")