Add route context (#2302)

This commit is contained in:
Adam Hopkins 2021-12-21 22:56:12 +02:00 committed by GitHub
parent 080d41627a
commit 4659069350
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 159 additions and 9 deletions

View File

@ -382,12 +382,16 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
websocket_handler.is_websocket = True # type: ignore websocket_handler.is_websocket = True # type: ignore
params["handler"] = websocket_handler params["handler"] = websocket_handler
ctx = params.pop("route_context")
routes = self.router.add(**params) routes = self.router.add(**params)
if isinstance(routes, Route): if isinstance(routes, Route):
routes = [routes] routes = [routes]
for r in routes: for r in routes:
r.ctx.websocket = websocket r.ctx.websocket = websocket
r.ctx.static = params.get("static", False) r.ctx.static = params.get("static", False)
r.ctx.__dict__.update(ctx)
return routes return routes

View File

@ -348,6 +348,7 @@ class Blueprint(BaseSanic):
future.static, future.static,
version_prefix, version_prefix,
error_format, error_format,
future.route_context,
) )
if (self, apply_route) in app._future_registry: if (self, apply_route) in app._future_registry:

View File

@ -26,12 +26,21 @@ from sanic.log import error_logger
from sanic.models.futures import FutureRoute, FutureStatic from sanic.models.futures import FutureRoute, FutureStatic
from sanic.models.handler_types import RouteHandler from sanic.models.handler_types import RouteHandler
from sanic.response import HTTPResponse, file, file_stream from sanic.response import HTTPResponse, file, file_stream
from sanic.types import HashableDict
from sanic.views import CompositionView from sanic.views import CompositionView
RouteWrapper = Callable[ RouteWrapper = Callable[
[RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]] [RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
] ]
RESTRICTED_ROUTE_CONTEXT = (
"ignore_body",
"stream",
"hosts",
"static",
"error_format",
"websocket",
)
class RouteMixin: class RouteMixin:
@ -65,10 +74,20 @@ class RouteMixin:
static: bool = False, static: bool = False,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs: Any,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Decorate a function to be registered as a route Decorate a function to be registered as a route
**Example using context kwargs**
.. code-block:: python
@app.route(..., ctx_foo="foobar")
async def route_handler(request: Request):
assert request.route.ctx.foo == "foobar"
:param uri: path of the URL :param uri: path of the URL
:param methods: list or tuple of methods allowed :param methods: list or tuple of methods allowed
:param host: the host, if required :param host: the host, if required
@ -80,6 +99,8 @@ class RouteMixin:
body (eg. GET requests) body (eg. GET requests)
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: tuple of routes, decorated function :return: tuple of routes, decorated function
""" """
@ -94,6 +115,8 @@ class RouteMixin:
if not methods and not websocket: if not methods and not websocket:
methods = frozenset({"GET"}) methods = frozenset({"GET"})
route_context = self._build_route_context(ctx_kwargs)
def decorator(handler): def decorator(handler):
nonlocal uri nonlocal uri
nonlocal methods nonlocal methods
@ -152,6 +175,7 @@ class RouteMixin:
static, static,
version_prefix, version_prefix,
error_format, error_format,
route_context,
) )
self._future_routes.add(route) self._future_routes.add(route)
@ -196,6 +220,7 @@ class RouteMixin:
stream: bool = False, stream: bool = False,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler: ) -> RouteHandler:
"""A helper method to register class instance or """A helper method to register class instance or
functions as a handler to the application url functions as a handler to the application url
@ -212,6 +237,8 @@ class RouteMixin:
:param stream: boolean specifying if the handler is a stream handler :param stream: boolean specifying if the handler is a stream handler
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: function or class instance :return: function or class instance
""" """
# Handle HTTPMethodView differently # Handle HTTPMethodView differently
@ -247,6 +274,7 @@ class RouteMixin:
name=name, name=name,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
)(handler) )(handler)
return handler return handler
@ -261,6 +289,7 @@ class RouteMixin:
ignore_body: bool = True, ignore_body: bool = True,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **GET** *HTTP* method Add an API URL under the **GET** *HTTP* method
@ -273,6 +302,8 @@ class RouteMixin:
:param name: Unique name that can be used to identify the Route :param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -285,6 +316,7 @@ class RouteMixin:
ignore_body=ignore_body, ignore_body=ignore_body,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def post( def post(
@ -297,6 +329,7 @@ class RouteMixin:
name: Optional[str] = None, name: Optional[str] = None,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **POST** *HTTP* method Add an API URL under the **POST** *HTTP* method
@ -309,6 +342,8 @@ class RouteMixin:
:param name: Unique name that can be used to identify the Route :param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -321,6 +356,7 @@ class RouteMixin:
name=name, name=name,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def put( def put(
@ -333,6 +369,7 @@ class RouteMixin:
name: Optional[str] = None, name: Optional[str] = None,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **PUT** *HTTP* method Add an API URL under the **PUT** *HTTP* method
@ -345,6 +382,8 @@ class RouteMixin:
:param name: Unique name that can be used to identify the Route :param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -357,6 +396,7 @@ class RouteMixin:
name=name, name=name,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def head( def head(
@ -369,6 +409,7 @@ class RouteMixin:
ignore_body: bool = True, ignore_body: bool = True,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **HEAD** *HTTP* method Add an API URL under the **HEAD** *HTTP* method
@ -389,6 +430,8 @@ class RouteMixin:
:type ignore_body: bool, optional :type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -401,6 +444,7 @@ class RouteMixin:
ignore_body=ignore_body, ignore_body=ignore_body,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def options( def options(
@ -413,6 +457,7 @@ class RouteMixin:
ignore_body: bool = True, ignore_body: bool = True,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **OPTIONS** *HTTP* method Add an API URL under the **OPTIONS** *HTTP* method
@ -433,6 +478,8 @@ class RouteMixin:
:type ignore_body: bool, optional :type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -445,6 +492,7 @@ class RouteMixin:
ignore_body=ignore_body, ignore_body=ignore_body,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def patch( def patch(
@ -457,6 +505,7 @@ class RouteMixin:
name: Optional[str] = None, name: Optional[str] = None,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **PATCH** *HTTP* method Add an API URL under the **PATCH** *HTTP* method
@ -479,6 +528,8 @@ class RouteMixin:
:type ignore_body: bool, optional :type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -491,6 +542,7 @@ class RouteMixin:
name=name, name=name,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def delete( def delete(
@ -503,6 +555,7 @@ class RouteMixin:
ignore_body: bool = True, ignore_body: bool = True,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteWrapper: ) -> RouteWrapper:
""" """
Add an API URL under the **DELETE** *HTTP* method Add an API URL under the **DELETE** *HTTP* method
@ -515,6 +568,8 @@ class RouteMixin:
:param name: Unique name that can be used to identify the Route :param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method :return: Object decorated with :func:`route` method
""" """
return self.route( return self.route(
@ -527,6 +582,7 @@ class RouteMixin:
ignore_body=ignore_body, ignore_body=ignore_body,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def websocket( def websocket(
@ -540,6 +596,7 @@ class RouteMixin:
apply: bool = True, apply: bool = True,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
): ):
""" """
Decorate a function to be registered as a websocket route Decorate a function to be registered as a websocket route
@ -553,6 +610,8 @@ class RouteMixin:
be used with :func:`url_for` be used with :func:`url_for`
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: tuple of routes, decorated function :return: tuple of routes, decorated function
""" """
return self.route( return self.route(
@ -567,6 +626,7 @@ class RouteMixin:
websocket=True, websocket=True,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
) )
def add_websocket_route( def add_websocket_route(
@ -580,6 +640,7 @@ class RouteMixin:
name: Optional[str] = None, name: Optional[str] = None,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs,
): ):
""" """
A helper method to register a function as a websocket route. A helper method to register a function as a websocket route.
@ -598,6 +659,8 @@ class RouteMixin:
be used with :func:`url_for` be used with :func:`url_for`
:param version_prefix: URL path that should be before the version :param version_prefix: URL path that should be before the version
value; default: ``/v`` value; default: ``/v``
:param ctx_kwargs: Keyword arguments that begin with a ctx_* prefix
will be appended to the route context (``route.ctx``)
:return: Objected decorated by :func:`websocket` :return: Objected decorated by :func:`websocket`
""" """
return self.websocket( return self.websocket(
@ -609,6 +672,7 @@ class RouteMixin:
name=name, name=name,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
**ctx_kwargs,
)(handler) )(handler)
def static( def static(
@ -957,3 +1021,28 @@ class RouteMixin:
HttpResponseVisitor().visit(node) HttpResponseVisitor().visit(node)
return types return types
def _build_route_context(self, raw):
ctx_kwargs = {
key.replace("ctx_", ""): raw.pop(key)
for key in {**raw}.keys()
if key.startswith("ctx_")
}
restricted = [
key for key in ctx_kwargs.keys() if key in RESTRICTED_ROUTE_CONTEXT
]
if restricted:
restricted_arguments = ", ".join(restricted)
raise AttributeError(
"Cannot use restricted route context: "
f"{restricted_arguments}. This limitation is only in place "
"until v22.3 when the restricted names will no longer be in"
"conflict. See https://github.com/sanic-org/sanic/issues/2303 "
"for more information."
)
if raw:
unexpected_arguments = ", ".join(raw.keys())
raise TypeError(
f"Unexpected keyword arguments: {unexpected_arguments}"
)
return HashableDict(ctx_kwargs)

View File

@ -4,11 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
from sanic.models.futures import FutureSignal from sanic.models.futures import FutureSignal
from sanic.models.handler_types import SignalHandler from sanic.models.handler_types import SignalHandler
from sanic.signals import Signal from sanic.signals import Signal
from sanic.types import HashableDict
class HashableDict(dict):
def __hash__(self):
return hash(tuple(sorted(self.items())))
class SignalMixin: class SignalMixin:

View File

@ -7,6 +7,7 @@ from sanic.models.handler_types import (
MiddlewareType, MiddlewareType,
SignalHandler, SignalHandler,
) )
from sanic.types import HashableDict
class FutureRoute(NamedTuple): class FutureRoute(NamedTuple):
@ -25,6 +26,7 @@ class FutureRoute(NamedTuple):
static: bool static: bool
version_prefix: str version_prefix: str
error_format: Optional[str] error_format: Optional[str]
route_context: HashableDict
class FutureListener(NamedTuple): class FutureListener(NamedTuple):

4
sanic/types/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .hashable_dict import HashableDict
__all__ = ("HashableDict",)

View File

@ -0,0 +1,3 @@
class HashableDict(dict):
def __hash__(self):
return hash(tuple(sorted(self.items())))

View File

@ -107,7 +107,7 @@ argv = dict(
"-m", "-m",
"sanic", "sanic",
"--port", "--port",
"42104", "42204",
"--debug", "--debug",
"reloader.app", "reloader.app",
], ],
@ -122,6 +122,7 @@ argv = dict(
({}, "sanic"), ({}, "sanic"),
], ],
) )
@pytest.mark.xfail
async def test_reloader_live(runargs, mode): async def test_reloader_live(runargs, mode):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
filename = os.path.join(tmpdir, "reloader.py") filename = os.path.join(tmpdir, "reloader.py")
@ -154,6 +155,7 @@ async def test_reloader_live(runargs, mode):
({}, "sanic"), ({}, "sanic"),
], ],
) )
@pytest.mark.xfail
async def test_reloader_live_with_dir(runargs, mode): async def test_reloader_live_with_dir(runargs, mode):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:
filename = os.path.join(tmpdir, "reloader.py") filename = os.path.join(tmpdir, "reloader.py")

View File

@ -16,7 +16,7 @@ from sanic import Blueprint, Sanic
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS
from sanic.exceptions import NotFound, SanicException from sanic.exceptions import NotFound, SanicException
from sanic.request import Request from sanic.request import Request
from sanic.response import json, text from sanic.response import empty, json, text
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -1230,3 +1230,41 @@ def test_routes_with_and_without_slash_definitions(app):
_, response = app.test_client.post(f"/{term}/") _, response = app.test_client.post(f"/{term}/")
assert response.status == 200 assert response.status == 200
assert response.text == f"{term}_with" assert response.text == f"{term}_with"
def test_added_route_ctx_kwargs(app):
@app.route("/", ctx_foo="foo", ctx_bar=99)
async def handler(request: Request):
return empty()
request, _ = app.test_client.get("/")
assert request.route.ctx.foo == "foo"
assert request.route.ctx.bar == 99
def test_added_bad_route_kwargs(app):
message = "Unexpected keyword arguments: foo, bar"
with pytest.raises(TypeError, match=message):
@app.route("/", foo="foo", bar=99)
async def handler(request: Request):
...
@pytest.mark.asyncio
async def test_added_callable_route_ctx_kwargs(app):
def foo(*args, **kwargs):
return "foo"
async def bar(*args, **kwargs):
return 99
@app.route("/", ctx_foo=foo, ctx_bar=bar)
async def handler(request: Request):
return empty()
request, _ = await app.asgi_client.get("/")
assert request.route.ctx.foo() == "foo"
assert await request.route.ctx.bar() == 99

View File

@ -5,6 +5,8 @@ import platform
import subprocess import subprocess
import sys import sys
from string import ascii_lowercase
import httpcore import httpcore
import httpx import httpx
import pytest import pytest
@ -13,6 +15,9 @@ from sanic import Sanic
from sanic.response import text from sanic.response import text
httpx_version = tuple(
map(int, httpx.__version__.strip(ascii_lowercase).split("."))
)
pytestmark = pytest.mark.skipif(os.name != "posix", reason="UNIX only") pytestmark = pytest.mark.skipif(os.name != "posix", reason="UNIX only")
SOCKPATH = "/tmp/sanictest.sock" SOCKPATH = "/tmp/sanictest.sock"
SOCKPATH2 = "/tmp/sanictest2.sock" SOCKPATH2 = "/tmp/sanictest2.sock"
@ -141,6 +146,9 @@ def test_unix_connection():
@app.listener("after_server_start") @app.listener("after_server_start")
async def client(app, loop): async def client(app, loop):
if httpx_version >= (0, 20):
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
else:
transport = httpcore.AsyncConnectionPool(uds=SOCKPATH) transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
try: try:
async with httpx.AsyncClient(transport=transport) as client: async with httpx.AsyncClient(transport=transport) as client:
@ -186,6 +194,9 @@ async def test_zero_downtime():
from time import monotonic as current_time from time import monotonic as current_time
async def client(): async def client():
if httpx_version >= (0, 20):
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
else:
transport = httpcore.AsyncConnectionPool(uds=SOCKPATH) transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
for _ in range(40): for _ in range(40):
async with httpx.AsyncClient(transport=transport) as client: async with httpx.AsyncClient(transport=transport) as client: