Skip to content

Commit

Permalink
Merge branch 'main' into fix-listener-typing
Browse files Browse the repository at this point in the history
  • Loading branch information
ahopkins authored Mar 23, 2022
2 parents e0943bf + 6e0a687 commit 0ae661a
Show file tree
Hide file tree
Showing 13 changed files with 275 additions and 24 deletions.
2 changes: 1 addition & 1 deletion examples/delayed_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async def handler(request):
return response.redirect("/sleep/3")


@app.get("/sleep/<t:number>")
@app.get("/sleep/<t:float>")
async def handler2(request, t=0.3):
await sleep(t)
return response.text(f"Slept {t:.1f} seconds.\n")
Expand Down
5 changes: 4 additions & 1 deletion sanic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 29 additions & 2 deletions sanic/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -113,21 +120,41 @@ 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]

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__

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):
Expand Down
12 changes: 12 additions & 0 deletions sanic/mixins/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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):
Expand Down Expand Up @@ -101,6 +103,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]:
Expand Down
12 changes: 10 additions & 2 deletions sanic/mixins/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
all_tasks,
get_event_loop,
get_running_loop,
new_event_loop,
)
from contextlib import suppress
from functools import partial
Expand All @@ -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
Expand Down Expand Up @@ -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, primary)
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, primary)
return

# This exists primarily for unit testing
if not primary.state.server_info: # no cov
Expand Down
5 changes: 3 additions & 2 deletions sanic/models/handler_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,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]]
47 changes: 47 additions & 0 deletions sanic/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
19 changes: 16 additions & 3 deletions sanic/server/events.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
9 changes: 9 additions & 0 deletions tests/fake/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 50 additions & 6 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -57,6 +58,49 @@ 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.server.create_app"]
out, err, exitcode = capture(command)
assert b"try: \nsanic fake.server.create_app --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",
(
Expand Down
Loading

0 comments on commit 0ae661a

Please sign in to comment.