From 0c9df02e6662120fa457e4e3d79c5c4e8f0bc4eb Mon Sep 17 00:00:00 2001 From: Bluenix Date: Mon, 14 Mar 2022 12:10:49 +0100 Subject: [PATCH 1/6] Add a docstring to `Request.respond()` (#2409) Co-authored-by: Ryu juheon Co-authored-by: Adam Hopkins --- sanic/request.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/sanic/request.py b/sanic/request.py index f5a69cc518..4e3510e5ab 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -197,6 +197,53 @@ async def respond( headers: Optional[Union[Header, Dict[str, str]]] = None, content_type: Optional[str] = None, ): + """Respond to the request without returning. + + This method can only be called once, as you can only respond once. + If no ``response`` argument is passed, one will be created from the + ``status``, ``headers`` and ``content_type`` arguments. + + **The first typical usecase** is if you wish to respond to the + request without returning from the handler: + + .. code-block:: python + + @app.get("/") + async def handler(request: Request): + data = ... # Process something + + json_response = json({"data": data}) + await request.respond(json_response) + + # You are now free to continue executing other code + ... + + @app.on_response + async def add_header(_, response: HTTPResponse): + # Middlewares still get executed as expected + response.headers["one"] = "two" + + **The second possible usecase** is for when you want to directly + respond to the request: + + .. code-block:: python + + response = await request.respond(content_type="text/csv") + await response.send("foo,") + await response.send("bar") + + # You can control the completion of the response by calling + # the 'eof()' method: + await response.eof() + + :param response: response instance to send + :param status: status code to return in the response + :param headers: headers to return in the response + :param content_type: Content-Type header of the response + :return: final response being sent (may be different from the + ``response`` parameter because of middlewares) which can be + used to manually send data + """ try: if self.stream is not None and self.stream.response: raise ServerError("Second respond call is not allowed.") From 2a8e91052f1e1320d2d427c44434d1d138a290a2 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 22 Mar 2022 23:29:39 +0200 Subject: [PATCH 2/6] Add two new events on the reloader process (#2413) --- sanic/mixins/listeners.py | 12 +++++++ sanic/mixins/runner.py | 12 +++++-- tests/test_reloader.py | 67 ++++++++++++++++++++++++++++++++++--- tests/test_server_events.py | 13 +++++++ 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/sanic/mixins/listeners.py b/sanic/mixins/listeners.py index cc8375f0d0..160b51b508 100644 --- a/sanic/mixins/listeners.py +++ b/sanic/mixins/listeners.py @@ -17,6 +17,8 @@ def _generate_next_value_(name: str, *args) -> str: # type: ignore AFTER_SERVER_STOP = "server.shutdown.after" MAIN_PROCESS_START = auto() MAIN_PROCESS_STOP = auto() + RELOAD_PROCESS_START = auto() + RELOAD_PROCESS_STOP = auto() class ListenerMixin(metaclass=SanicMeta): @@ -73,6 +75,16 @@ def main_process_stop( ) -> ListenerType[Sanic]: return self.listener(listener, "main_process_stop") + def reload_process_start( + self, listener: ListenerType[Sanic] + ) -> ListenerType[Sanic]: + return self.listener(listener, "reload_process_start") + + def reload_process_stop( + self, listener: ListenerType[Sanic] + ) -> ListenerType[Sanic]: + return self.listener(listener, "reload_process_stop") + def before_server_start( self, listener: ListenerType[Sanic] ) -> ListenerType[Sanic]: diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index 101a5b35d5..e5e3490dca 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -11,6 +11,7 @@ all_tasks, get_event_loop, get_running_loop, + new_event_loop, ) from contextlib import suppress from functools import partial @@ -32,6 +33,7 @@ from sanic.server import Signal as ServerSignal from sanic.server import try_use_uvloop from sanic.server.async_server import AsyncioServer +from sanic.server.events import trigger_events from sanic.server.protocols.http_protocol import HttpProtocol from sanic.server.protocols.websocket_protocol import WebSocketProtocol from sanic.server.runners import serve, serve_multiple, serve_single @@ -538,15 +540,21 @@ def serve(cls, primary: Optional[Sanic] = None) -> None: except IndexError: raise RuntimeError("Did not find any applications.") + reloader_start = primary.listeners.get("reload_process_start") + reloader_stop = primary.listeners.get("reload_process_stop") # We want to run auto_reload if ANY of the applications have it enabled if ( cls.should_auto_reload() and os.environ.get("SANIC_SERVER_RUNNING") != "true" - ): + ): # no cov + loop = new_event_loop() + trigger_events(reloader_start, loop) reload_dirs: Set[Path] = primary.state.reload_dirs.union( *(app.state.reload_dirs for app in apps) ) - return reloader_helpers.watchdog(1.0, reload_dirs) + reloader_helpers.watchdog(1.0, reload_dirs) + trigger_events(reloader_stop, loop) + return # This exists primarily for unit testing if not primary.state.server_info: # no cov diff --git a/tests/test_reloader.py b/tests/test_reloader.py index d78b49a954..ad8c566535 100644 --- a/tests/test_reloader.py +++ b/tests/test_reloader.py @@ -58,6 +58,36 @@ def complete(*args): return text +def write_listener_app(filename, **runargs): + start_text = secrets.token_urlsafe() + stop_text = secrets.token_urlsafe() + with open(filename, "w") as f: + f.write( + dedent( + f"""\ + import os + from sanic import Sanic + + app = Sanic(__name__) + + app.route("/")(lambda x: x) + + @app.reload_process_start + async def reload_start(*_): + print("reload_start", os.getpid(), {start_text!r}) + + @app.reload_process_stop + async def reload_stop(*_): + print("reload_stop", os.getpid(), {stop_text!r}) + + if __name__ == "__main__": + app.run(**{runargs!r}) + """ + ) + ) + return start_text, stop_text + + def write_json_config_app(filename, jsonfile, **runargs): with open(filename, "w") as f: f.write( @@ -92,10 +122,10 @@ def write_file(filename): return text -def scanner(proc): +def scanner(proc, trigger="complete"): for line in proc.stdout: line = line.decode().strip() - if line.startswith("complete"): + if line.startswith(trigger): yield line @@ -108,7 +138,7 @@ def scanner(proc): "sanic", "--port", "42204", - "--debug", + "--auto-reload", "reloader.app", ], ) @@ -118,7 +148,7 @@ def scanner(proc): "runargs, mode", [ (dict(port=42202, auto_reload=True), "script"), - (dict(port=42203, debug=True), "module"), + (dict(port=42203, auto_reload=True), "module"), ({}, "sanic"), ], ) @@ -151,7 +181,7 @@ async def test_reloader_live(runargs, mode): "runargs, mode", [ (dict(port=42302, auto_reload=True), "script"), - (dict(port=42303, debug=True), "module"), + (dict(port=42303, auto_reload=True), "module"), ({}, "sanic"), ], ) @@ -183,3 +213,30 @@ async def test_reloader_live_with_dir(runargs, mode): terminate(proc) with suppress(TimeoutExpired): proc.wait(timeout=3) + + +def test_reload_listeners(): + with TemporaryDirectory() as tmpdir: + filename = os.path.join(tmpdir, "reloader.py") + start_text, stop_text = write_listener_app( + filename, port=42305, auto_reload=True + ) + + proc = Popen( + argv["script"], cwd=tmpdir, stdout=PIPE, creationflags=flags + ) + try: + timeout = Timer(TIMER_DELAY, terminate, [proc]) + timeout.start() + # Python apparently keeps using the old source sometimes if + # we don't sleep before rewrite (pycache timestamp problem?) + sleep(1) + line = scanner(proc, "reload_start") + assert start_text in next(line) + line = scanner(proc, "reload_stop") + assert stop_text in next(line) + finally: + timeout.cancel() + terminate(proc) + with suppress(TimeoutExpired): + proc.wait(timeout=3) diff --git a/tests/test_server_events.py b/tests/test_server_events.py index cd3f526663..712b3c68bb 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -199,3 +199,16 @@ async def init_db(app, loop): with pytest.raises(SanicException): await srv.before_start() + + +def test_reload_listeners_attached(app): + async def dummy(*_): + ... + + app.reload_process_start(dummy) + app.reload_process_stop(dummy) + app.listener("reload_process_start")(dummy) + app.listener("reload_process_stop")(dummy) + + assert len(app.listeners.get("reload_process_start")) == 2 + assert len(app.listeners.get("reload_process_stop")) == 2 From 44b108b5649d1d55e1ef2d4ba1bb11e5d5f68c86 Mon Sep 17 00:00:00 2001 From: Callum <38749032+howzitcdf@users.noreply.github.com> Date: Wed, 23 Mar 2022 10:30:41 +0200 Subject: [PATCH 3/6] Changes to CLI (#2401) Co-authored-by: Callum Fleming Co-authored-by: Adam Hopkins --- sanic/cli/app.py | 19 ++++++++++++++++++- tests/fake/factory.py | 6 ++++++ tests/test_cli.py | 16 ++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/fake/factory.py diff --git a/sanic/cli/app.py b/sanic/cli/app.py index 95a572a8bd..db830e09a5 100644 --- a/sanic/cli/app.py +++ b/sanic/cli/app.py @@ -113,6 +113,14 @@ def _get_app(self): delimiter = ":" if ":" in self.args.module else "." module_name, app_name = self.args.module.rsplit(delimiter, 1) + if module_name == "" and os.path.isdir(self.args.module): + raise ValueError( + "App not found.\n" + " Please use --simple if you are passing a " + "directory to sanic.\n" + f" eg. sanic {self.args.module} --simple" + ) + if app_name.endswith("()"): self.args.factory = True app_name = app_name[:-2] @@ -125,9 +133,18 @@ def _get_app(self): app_type_name = type(app).__name__ if not isinstance(app, Sanic): + if callable(app): + solution = f"sanic {self.args.module} --factory" + raise ValueError( + "Module is not a Sanic app, it is a" + f"{app_type_name}\n" + " If this callable returns a" + f"Sanic instance try: \n{solution}" + ) + raise ValueError( f"Module is not a Sanic app, it is a {app_type_name}\n" - f" Perhaps you meant {self.args.module}.app?" + f" Perhaps you meant {self.args.module}:app?" ) except ImportError as e: if module_name.startswith(e.name): diff --git a/tests/fake/factory.py b/tests/fake/factory.py new file mode 100644 index 0000000000..17a815cd97 --- /dev/null +++ b/tests/fake/factory.py @@ -0,0 +1,6 @@ +from sanic import Sanic + + +def run(): + app = Sanic("FactoryTest") + return app diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ec1c28ce7..b12bb41454 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,6 +57,22 @@ def test_server_run(appname): assert firstline == b"Goin' Fast @ http://127.0.0.1:8000" +def test_error_with_function_as_instance_without_factory_arg(): + command = ["sanic", "fake.factory.run"] + out, err, exitcode = capture(command) + assert b"try: \nsanic fake.factory.run --factory" in err + assert exitcode != 1 + + +def test_error_with_path_as_instance_without_simple_arg(): + command = ["sanic", "./fake/"] + out, err, exitcode = capture(command) + assert ( + b"Please use --simple if you are passing a directory to sanic." in err + ) + assert exitcode != 1 + + @pytest.mark.parametrize( "cmd", ( From c9dbc8ed26b623a42eda115bef77bda0b96686f2 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 23 Mar 2022 11:02:39 +0200 Subject: [PATCH 4/6] Remove loop as required listener arg (#2414) --- sanic/app.py | 5 ++++- sanic/mixins/runner.py | 4 ++-- sanic/models/handler_types.py | 5 +++-- sanic/server/events.py | 19 ++++++++++++++++--- tests/test_server_events.py | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/sanic/app.py b/sanic/app.py index 95517f7aa7..67ba68fbce 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -1131,7 +1131,10 @@ def _cancel_websocket_tasks(cls, app, loop): async def _listener( app: Sanic, loop: AbstractEventLoop, listener: ListenerType ): - maybe_coro = listener(app, loop) + try: + maybe_coro = listener(app) # type: ignore + except TypeError: + maybe_coro = listener(app, loop) # type: ignore if maybe_coro and isawaitable(maybe_coro): await maybe_coro diff --git a/sanic/mixins/runner.py b/sanic/mixins/runner.py index e5e3490dca..1df77e551e 100644 --- a/sanic/mixins/runner.py +++ b/sanic/mixins/runner.py @@ -548,12 +548,12 @@ def serve(cls, primary: Optional[Sanic] = None) -> None: and os.environ.get("SANIC_SERVER_RUNNING") != "true" ): # no cov loop = new_event_loop() - trigger_events(reloader_start, loop) + trigger_events(reloader_start, loop, primary) reload_dirs: Set[Path] = primary.state.reload_dirs.union( *(app.state.reload_dirs for app in apps) ) reloader_helpers.watchdog(1.0, reload_dirs) - trigger_events(reloader_stop, loop) + trigger_events(reloader_stop, loop, primary) return # This exists primarily for unit testing diff --git a/sanic/models/handler_types.py b/sanic/models/handler_types.py index 0144c964d8..317f043231 100644 --- a/sanic/models/handler_types.py +++ b/sanic/models/handler_types.py @@ -18,8 +18,9 @@ [Request, BaseException], Optional[Coroutine[Any, Any, None]] ] MiddlewareType = Union[RequestMiddlewareType, ResponseMiddlewareType] -ListenerType = Callable[ - [Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]] +ListenerType = Union[ + Callable[[Sanic], Optional[Coroutine[Any, Any, None]]], + Callable[[Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]]], ] RouteHandler = Callable[..., Coroutine[Any, Any, Optional[HTTPResponse]]] SignalHandler = Callable[..., Coroutine[Any, Any, None]] diff --git a/sanic/server/events.py b/sanic/server/events.py index 3b71281d9e..41a89aea1d 100644 --- a/sanic/server/events.py +++ b/sanic/server/events.py @@ -1,8 +1,18 @@ +from __future__ import annotations + from inspect import isawaitable -from typing import Any, Callable, Iterable, Optional +from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional + + +if TYPE_CHECKING: # no cov + from sanic import Sanic -def trigger_events(events: Optional[Iterable[Callable[..., Any]]], loop): +def trigger_events( + events: Optional[Iterable[Callable[..., Any]]], + loop, + app: Optional[Sanic] = None, +): """ Trigger event callbacks (functions or async) @@ -11,6 +21,9 @@ def trigger_events(events: Optional[Iterable[Callable[..., Any]]], loop): """ if events: for event in events: - result = event(loop) + try: + result = event() if not app else event(app) + except TypeError: + result = event(loop) if not app else event(app, loop) if isawaitable(result): loop.run_until_complete(result) diff --git a/tests/test_server_events.py b/tests/test_server_events.py index 712b3c68bb..7a52965522 100644 --- a/tests/test_server_events.py +++ b/tests/test_server_events.py @@ -33,6 +33,14 @@ async def _listener(app, loop): return _listener +def create_listener_no_loop(listener_name, in_list): + async def _listener(app): + print(f"DEBUG MESSAGE FOR PYTEST for {listener_name}") + in_list.insert(0, app.name + listener_name) + + return _listener + + def start_stop_app(random_name_app, **run_kwargs): def stop_on_alarm(signum, frame): random_name_app.stop() @@ -56,6 +64,17 @@ def test_single_listener(app, listener_name): assert app.name + listener_name == output.pop() +@skipif_no_alarm +@pytest.mark.parametrize("listener_name", AVAILABLE_LISTENERS) +def test_single_listener_no_loop(app, listener_name): + """Test that listeners on their own work""" + output = [] + # Register listener + app.listener(listener_name)(create_listener_no_loop(listener_name, output)) + start_stop_app(app) + assert app.name + listener_name == output.pop() + + @skipif_no_alarm @pytest.mark.parametrize("listener_name", AVAILABLE_LISTENERS) def test_register_listener(app, listener_name): From 0030425c8c9c8aef43e30201646e544fdca738d7 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 23 Mar 2022 12:00:41 +0200 Subject: [PATCH 5/6] Conditionally inject CLI arguments into factory (#2402) --- sanic/cli/app.py | 12 +++++++++++- tests/fake/factory.py | 6 ------ tests/fake/server.py | 9 +++++++++ tests/test_cli.py | 44 +++++++++++++++++++++++++++++++++++-------- 4 files changed, 56 insertions(+), 15 deletions(-) delete mode 100644 tests/fake/factory.py diff --git a/sanic/cli/app.py b/sanic/cli/app.py index db830e09a5..c5d937c685 100644 --- a/sanic/cli/app.py +++ b/sanic/cli/app.py @@ -68,6 +68,13 @@ def run(self): legacy_version = len(sys.argv) == 2 and sys.argv[-1] == "-v" parse_args = ["--version"] if legacy_version else None + if not parse_args: + parsed, unknown = self.parser.parse_known_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) self._precheck() @@ -128,7 +135,10 @@ def _get_app(self): module = import_module(module_name) app = getattr(module, app_name, None) if self.args.factory: - app = app() + try: + app = app(self.args) + except TypeError: + app = app() app_type_name = type(app).__name__ diff --git a/tests/fake/factory.py b/tests/fake/factory.py deleted file mode 100644 index 17a815cd97..0000000000 --- a/tests/fake/factory.py +++ /dev/null @@ -1,6 +0,0 @@ -from sanic import Sanic - - -def run(): - app = Sanic("FactoryTest") - return app diff --git a/tests/fake/server.py b/tests/fake/server.py index 1220c23e84..f7941fa374 100644 --- a/tests/fake/server.py +++ b/tests/fake/server.py @@ -34,3 +34,12 @@ async def shutdown(app: Sanic, _): def create_app(): return app + + +def create_app_with_args(args): + try: + print(f"foo={args.foo}") + except AttributeError: + print(f"module={args.module}") + + return app diff --git a/tests/test_cli.py b/tests/test_cli.py index b12bb41454..f77e345355 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,16 +39,17 @@ def read_app_info(lines): @pytest.mark.parametrize( - "appname", + "appname,extra", ( - "fake.server.app", - "fake.server:app", - "fake.server:create_app()", - "fake.server.create_app()", + ("fake.server.app", None), + ("fake.server:create_app", "--factory"), + ("fake.server.create_app()", None), ), ) -def test_server_run(appname): +def test_server_run(appname, extra): command = ["sanic", appname] + if extra: + command.append(extra) out, err, exitcode = capture(command) lines = out.split(b"\n") firstline = lines[starting_line(lines) + 1] @@ -57,10 +58,37 @@ def test_server_run(appname): assert firstline == b"Goin' Fast @ http://127.0.0.1:8000" +def test_server_run_factory_with_args(): + command = [ + "sanic", + "fake.server.create_app_with_args", + "--factory", + ] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + + assert exitcode != 1, lines + assert b"module=fake.server.create_app_with_args" in lines + + +def test_server_run_factory_with_args_arbitrary(): + command = [ + "sanic", + "fake.server.create_app_with_args", + "--factory", + "--foo=bar", + ] + out, err, exitcode = capture(command) + lines = out.split(b"\n") + + assert exitcode != 1, lines + assert b"foo=bar" in lines + + def test_error_with_function_as_instance_without_factory_arg(): - command = ["sanic", "fake.factory.run"] + command = ["sanic", "fake.server.create_app"] out, err, exitcode = capture(command) - assert b"try: \nsanic fake.factory.run --factory" in err + assert b"try: \nsanic fake.server.create_app --factory" in err assert exitcode != 1 From 6e0a6871b5665fbccaef52f8ef336d68b0e13603 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Wed, 23 Mar 2022 13:43:36 +0200 Subject: [PATCH 6/6] Upgrade tests for sanic-routing changes (#2405) --- examples/delayed_response.py | 2 +- tests/test_routes.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/delayed_response.py b/examples/delayed_response.py index 3fab68121b..141bf06a73 100644 --- a/examples/delayed_response.py +++ b/examples/delayed_response.py @@ -11,7 +11,7 @@ async def handler(request): return response.redirect("/sleep/3") -@app.get("/sleep/") +@app.get("/sleep/") async def handler2(request, t=0.3): await sleep(t) return response.text(f"Slept {t:.1f} seconds.\n") diff --git a/tests/test_routes.py b/tests/test_routes.py index 3a7674c54e..be23909f04 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -1,8 +1,6 @@ import asyncio import re -from unittest.mock import Mock - import pytest from sanic_routing.exceptions import (