Add route context (#2302)
This commit is contained in:
parent
080d41627a
commit
4659069350
|
@ -382,12 +382,16 @@ class Sanic(BaseSanic, metaclass=TouchUpMeta):
|
|||
websocket_handler.is_websocket = True # type: ignore
|
||||
params["handler"] = websocket_handler
|
||||
|
||||
ctx = params.pop("route_context")
|
||||
|
||||
routes = self.router.add(**params)
|
||||
if isinstance(routes, Route):
|
||||
routes = [routes]
|
||||
|
||||
for r in routes:
|
||||
r.ctx.websocket = websocket
|
||||
r.ctx.static = params.get("static", False)
|
||||
r.ctx.__dict__.update(ctx)
|
||||
|
||||
return routes
|
||||
|
||||
|
|
|
@ -348,6 +348,7 @@ class Blueprint(BaseSanic):
|
|||
future.static,
|
||||
version_prefix,
|
||||
error_format,
|
||||
future.route_context,
|
||||
)
|
||||
|
||||
if (self, apply_route) in app._future_registry:
|
||||
|
|
|
@ -26,12 +26,21 @@ from sanic.log import error_logger
|
|||
from sanic.models.futures import FutureRoute, FutureStatic
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
from sanic.response import HTTPResponse, file, file_stream
|
||||
from sanic.types import HashableDict
|
||||
from sanic.views import CompositionView
|
||||
|
||||
|
||||
RouteWrapper = Callable[
|
||||
[RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
|
||||
]
|
||||
RESTRICTED_ROUTE_CONTEXT = (
|
||||
"ignore_body",
|
||||
"stream",
|
||||
"hosts",
|
||||
"static",
|
||||
"error_format",
|
||||
"websocket",
|
||||
)
|
||||
|
||||
|
||||
class RouteMixin:
|
||||
|
@ -65,10 +74,20 @@ class RouteMixin:
|
|||
static: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
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 methods: list or tuple of methods allowed
|
||||
:param host: the host, if required
|
||||
|
@ -80,6 +99,8 @@ class RouteMixin:
|
|||
body (eg. GET requests)
|
||||
:param version_prefix: URL path that should be before the version
|
||||
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
|
||||
"""
|
||||
|
||||
|
@ -94,6 +115,8 @@ class RouteMixin:
|
|||
if not methods and not websocket:
|
||||
methods = frozenset({"GET"})
|
||||
|
||||
route_context = self._build_route_context(ctx_kwargs)
|
||||
|
||||
def decorator(handler):
|
||||
nonlocal uri
|
||||
nonlocal methods
|
||||
|
@ -152,6 +175,7 @@ class RouteMixin:
|
|||
static,
|
||||
version_prefix,
|
||||
error_format,
|
||||
route_context,
|
||||
)
|
||||
|
||||
self._future_routes.add(route)
|
||||
|
@ -196,6 +220,7 @@ class RouteMixin:
|
|||
stream: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteHandler:
|
||||
"""A helper method to register class instance or
|
||||
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 version_prefix: URL path that should be before the version
|
||||
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
|
||||
"""
|
||||
# Handle HTTPMethodView differently
|
||||
|
@ -247,6 +274,7 @@ class RouteMixin:
|
|||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)(handler)
|
||||
return handler
|
||||
|
||||
|
@ -261,6 +289,7 @@ class RouteMixin:
|
|||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
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 version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -285,6 +316,7 @@ class RouteMixin:
|
|||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def post(
|
||||
|
@ -297,6 +329,7 @@ class RouteMixin:
|
|||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
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 version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -321,6 +356,7 @@ class RouteMixin:
|
|||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def put(
|
||||
|
@ -333,6 +369,7 @@ class RouteMixin:
|
|||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
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 version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -357,6 +396,7 @@ class RouteMixin:
|
|||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def head(
|
||||
|
@ -369,6 +409,7 @@ class RouteMixin:
|
|||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **HEAD** *HTTP* method
|
||||
|
@ -389,6 +430,8 @@ class RouteMixin:
|
|||
:type ignore_body: bool, optional
|
||||
:param version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -401,6 +444,7 @@ class RouteMixin:
|
|||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def options(
|
||||
|
@ -413,6 +457,7 @@ class RouteMixin:
|
|||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **OPTIONS** *HTTP* method
|
||||
|
@ -433,6 +478,8 @@ class RouteMixin:
|
|||
:type ignore_body: bool, optional
|
||||
:param version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -445,6 +492,7 @@ class RouteMixin:
|
|||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def patch(
|
||||
|
@ -457,6 +505,7 @@ class RouteMixin:
|
|||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **PATCH** *HTTP* method
|
||||
|
@ -479,6 +528,8 @@ class RouteMixin:
|
|||
:type ignore_body: bool, optional
|
||||
:param version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -491,6 +542,7 @@ class RouteMixin:
|
|||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def delete(
|
||||
|
@ -503,6 +555,7 @@ class RouteMixin:
|
|||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
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 version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -527,6 +582,7 @@ class RouteMixin:
|
|||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def websocket(
|
||||
|
@ -540,6 +596,7 @@ class RouteMixin:
|
|||
apply: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
):
|
||||
"""
|
||||
Decorate a function to be registered as a websocket route
|
||||
|
@ -553,6 +610,8 @@ class RouteMixin:
|
|||
be used with :func:`url_for`
|
||||
:param version_prefix: URL path that should be before the version
|
||||
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 self.route(
|
||||
|
@ -567,6 +626,7 @@ class RouteMixin:
|
|||
websocket=True,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def add_websocket_route(
|
||||
|
@ -580,6 +640,7 @@ class RouteMixin:
|
|||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
**ctx_kwargs,
|
||||
):
|
||||
"""
|
||||
A helper method to register a function as a websocket route.
|
||||
|
@ -598,6 +659,8 @@ class RouteMixin:
|
|||
be used with :func:`url_for`
|
||||
:param version_prefix: URL path that should be before the version
|
||||
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 self.websocket(
|
||||
|
@ -609,6 +672,7 @@ class RouteMixin:
|
|||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)(handler)
|
||||
|
||||
def static(
|
||||
|
@ -957,3 +1021,28 @@ class RouteMixin:
|
|||
HttpResponseVisitor().visit(node)
|
||||
|
||||
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)
|
||||
|
|
|
@ -4,11 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
|
|||
from sanic.models.futures import FutureSignal
|
||||
from sanic.models.handler_types import SignalHandler
|
||||
from sanic.signals import Signal
|
||||
|
||||
|
||||
class HashableDict(dict):
|
||||
def __hash__(self):
|
||||
return hash(tuple(sorted(self.items())))
|
||||
from sanic.types import HashableDict
|
||||
|
||||
|
||||
class SignalMixin:
|
||||
|
|
|
@ -7,6 +7,7 @@ from sanic.models.handler_types import (
|
|||
MiddlewareType,
|
||||
SignalHandler,
|
||||
)
|
||||
from sanic.types import HashableDict
|
||||
|
||||
|
||||
class FutureRoute(NamedTuple):
|
||||
|
@ -25,6 +26,7 @@ class FutureRoute(NamedTuple):
|
|||
static: bool
|
||||
version_prefix: str
|
||||
error_format: Optional[str]
|
||||
route_context: HashableDict
|
||||
|
||||
|
||||
class FutureListener(NamedTuple):
|
||||
|
|
4
sanic/types/__init__.py
Normal file
4
sanic/types/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from .hashable_dict import HashableDict
|
||||
|
||||
|
||||
__all__ = ("HashableDict",)
|
3
sanic/types/hashable_dict.py
Normal file
3
sanic/types/hashable_dict.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
class HashableDict(dict):
|
||||
def __hash__(self):
|
||||
return hash(tuple(sorted(self.items())))
|
|
@ -107,7 +107,7 @@ argv = dict(
|
|||
"-m",
|
||||
"sanic",
|
||||
"--port",
|
||||
"42104",
|
||||
"42204",
|
||||
"--debug",
|
||||
"reloader.app",
|
||||
],
|
||||
|
@ -122,6 +122,7 @@ argv = dict(
|
|||
({}, "sanic"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.xfail
|
||||
async def test_reloader_live(runargs, mode):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, "reloader.py")
|
||||
|
@ -154,6 +155,7 @@ async def test_reloader_live(runargs, mode):
|
|||
({}, "sanic"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.xfail
|
||||
async def test_reloader_live_with_dir(runargs, mode):
|
||||
with TemporaryDirectory() as tmpdir:
|
||||
filename = os.path.join(tmpdir, "reloader.py")
|
||||
|
|
|
@ -16,7 +16,7 @@ from sanic import Blueprint, Sanic
|
|||
from sanic.constants import HTTP_METHODS
|
||||
from sanic.exceptions import NotFound, SanicException
|
||||
from sanic.request import Request
|
||||
from sanic.response import json, text
|
||||
from sanic.response import empty, json, text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -1230,3 +1230,41 @@ def test_routes_with_and_without_slash_definitions(app):
|
|||
_, response = app.test_client.post(f"/{term}/")
|
||||
assert response.status == 200
|
||||
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
|
||||
|
|
|
@ -5,6 +5,8 @@ import platform
|
|||
import subprocess
|
||||
import sys
|
||||
|
||||
from string import ascii_lowercase
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
import pytest
|
||||
|
@ -13,6 +15,9 @@ from sanic import Sanic
|
|||
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")
|
||||
SOCKPATH = "/tmp/sanictest.sock"
|
||||
SOCKPATH2 = "/tmp/sanictest2.sock"
|
||||
|
@ -141,6 +146,9 @@ def test_unix_connection():
|
|||
|
||||
@app.listener("after_server_start")
|
||||
async def client(app, loop):
|
||||
if httpx_version >= (0, 20):
|
||||
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
|
||||
else:
|
||||
transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
|
||||
try:
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
|
@ -186,6 +194,9 @@ async def test_zero_downtime():
|
|||
from time import monotonic as current_time
|
||||
|
||||
async def client():
|
||||
if httpx_version >= (0, 20):
|
||||
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
|
||||
else:
|
||||
transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
|
||||
for _ in range(40):
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
|
|
Loading…
Reference in New Issue
Block a user