Files
sanic/sanic/blueprints.py
2023-09-06 15:44:00 +03:00

966 lines
31 KiB
Python

from __future__ import annotations
import asyncio
import sys
from collections import defaultdict
from collections.abc import MutableSequence
from copy import deepcopy
from functools import partial, wraps
from inspect import isfunction
from itertools import chain
from types import SimpleNamespace
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
Union,
overload,
)
from sanic_routing.exceptions import NotFound
from sanic_routing.route import Route
from sanic.base.root import BaseSanic
from sanic.exceptions import SanicException
from sanic.helpers import Default, _default
from sanic.models.futures import FutureRoute, FutureStatic
from sanic.models.handler_types import (
ListenerType,
MiddlewareType,
RouteHandler,
)
if TYPE_CHECKING:
from sanic import Sanic
def lazy(func, as_decorator=True):
"""Decorator to register a function to be called later.
Args:
func (Callable): Function to be called later.
as_decorator (bool): Whether the function should be called
immediately or not.
"""
@wraps(func)
def decorator(bp, *args, **kwargs):
nonlocal as_decorator
kwargs["apply"] = False
pass_handler = None
if args and isfunction(args[0]):
as_decorator = False
def wrapper(handler):
future = func(bp, *args, **kwargs)
if as_decorator:
future = future(handler)
if bp.registered:
for app in bp.apps:
bp.register(app, {})
return future
return wrapper if as_decorator else wrapper(pass_handler)
return decorator
class Blueprint(BaseSanic):
"""A logical collection of URLs that consist of a similar logical domain.
A Blueprint object is the main tool for grouping functionality and similar endpoints. It allows the developer to
organize routes, exception handlers, middleware, and other web functionalities into separate, modular groups.
See [Blueprints](/en/guide/best-practices/blueprints) for more information.
Args:
name (str): The name of the blueprint.
url_prefix (Optional[str]): The URL prefix for all routes defined on this blueprint.
host (Optional[Union[List[str], str]]): Host or list of hosts that this blueprint should respond to.
version (Optional[Union[int, str, float]]): Version number of the API implemented by this blueprint.
strict_slashes (Optional[bool]): Whether or not the URL should end with a slash.
version_prefix (str): Prefix for the version. Default is "/v".
""" # noqa: E501
__slots__ = (
"_apps",
"_future_routes",
"_future_statics",
"_future_middleware",
"_future_listeners",
"_future_exceptions",
"_future_signals",
"_allow_route_overwrite",
"copied_from",
"ctx",
"exceptions",
"host",
"listeners",
"middlewares",
"routes",
"statics",
"strict_slashes",
"url_prefix",
"version",
"version_prefix",
"websocket_routes",
)
def __init__(
self,
name: str,
url_prefix: Optional[str] = None,
host: Optional[Union[List[str], str]] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
):
super().__init__(name=name)
self.reset()
self._allow_route_overwrite = False
self.copied_from = ""
self.ctx = SimpleNamespace()
self.host = host
self.strict_slashes = strict_slashes
self.url_prefix = (
url_prefix[:-1]
if url_prefix and url_prefix.endswith("/")
else url_prefix
)
self.version = version
self.version_prefix = version_prefix
def __repr__(self) -> str:
args = ", ".join(
[
f'{attr}="{getattr(self, attr)}"'
if isinstance(getattr(self, attr), str)
else f"{attr}={getattr(self, attr)}"
for attr in (
"name",
"url_prefix",
"host",
"version",
"strict_slashes",
)
]
)
return f"Blueprint({args})"
@property
def apps(self) -> Set[Sanic]:
"""Get the set of apps that this blueprint is registered to.
Returns:
Set[Sanic]: Set of apps that this blueprint is registered to.
Raises:
SanicException: If the blueprint has not yet been registered to
an app.
"""
if not self._apps:
raise SanicException(
f"{self} has not yet been registered to an app"
)
return self._apps
@property
def registered(self) -> bool:
"""Check if the blueprint has been registered to an app.
Returns:
bool: `True` if the blueprint has been registered to an app,
`False` otherwise.
"""
return bool(self._apps)
exception = lazy(BaseSanic.exception)
listener = lazy(BaseSanic.listener)
middleware = lazy(BaseSanic.middleware)
route = lazy(BaseSanic.route)
signal = lazy(BaseSanic.signal)
static = lazy(BaseSanic.static, as_decorator=False)
def reset(self) -> None:
"""Reset the blueprint to its initial state."""
self._apps: Set[Sanic] = set()
self._allow_route_overwrite = False
self.exceptions: List[RouteHandler] = []
self.listeners: Dict[str, List[ListenerType[Any]]] = {}
self.middlewares: List[MiddlewareType] = []
self.routes: List[Route] = []
self.statics: List[RouteHandler] = []
self.websocket_routes: List[Route] = []
def copy(
self,
name: str,
url_prefix: Optional[Union[str, Default]] = _default,
version: Optional[Union[int, str, float, Default]] = _default,
version_prefix: Union[str, Default] = _default,
allow_route_overwrite: Union[bool, Default] = _default,
strict_slashes: Optional[Union[bool, Default]] = _default,
with_registration: bool = True,
with_ctx: bool = False,
):
"""Copy a blueprint instance with some optional parameters to override the values of attributes in the old instance.
Args:
name (str): Unique name of the blueprint.
url_prefix (Optional[Union[str, Default]]): URL to be prefixed before all route URLs.
version (Optional[Union[int, str, float, Default]]): Blueprint version.
version_prefix (Union[str, Default]): The prefix of the version number shown in the URL.
allow_route_overwrite (Union[bool, Default]): Whether to allow route overwrite or not.
strict_slashes (Optional[Union[bool, Default]]): Enforce the API URLs are requested with a trailing "/*".
with_registration (bool): Whether to register the new blueprint instance with Sanic apps that were registered with the old instance or not. Default is `True`.
with_ctx (bool): Whether the ``ctx`` will be copied or not. Default is `False`.
Returns:
Blueprint: A new Blueprint instance with the specified attributes.
""" # noqa: E501
attrs_backup = {
"_apps": self._apps,
"routes": self.routes,
"websocket_routes": self.websocket_routes,
"middlewares": self.middlewares,
"exceptions": self.exceptions,
"listeners": self.listeners,
"statics": self.statics,
}
self.reset()
new_bp = deepcopy(self)
new_bp.name = name
new_bp.copied_from = self.name
if not isinstance(url_prefix, Default):
new_bp.url_prefix = url_prefix
if not isinstance(version, Default):
new_bp.version = version
if not isinstance(strict_slashes, Default):
new_bp.strict_slashes = strict_slashes
if not isinstance(version_prefix, Default):
new_bp.version_prefix = version_prefix
if not isinstance(allow_route_overwrite, Default):
new_bp._allow_route_overwrite = allow_route_overwrite
for key, value in attrs_backup.items():
setattr(self, key, value)
if with_registration and self._apps:
if new_bp._future_statics:
raise SanicException(
"Static routes registered with the old blueprint instance,"
" cannot be registered again."
)
for app in self._apps:
app.blueprint(new_bp)
if not with_ctx:
new_bp.ctx = SimpleNamespace()
return new_bp
@staticmethod
def group(
*blueprints: Union[Blueprint, BlueprintGroup],
url_prefix: Optional[str] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
name_prefix: Optional[str] = "",
) -> BlueprintGroup:
"""Group multiple blueprints (or other blueprint groups) together.
Gropuping blueprings is a method for modularizing and organizing
your application's code. This can be a powerful tool for creating
reusable components, logically structuring your application code,
and easily maintaining route definitions in bulk.
This is the preferred way to group multiple blueprints together.
Args:
blueprints (Union[Blueprint, BlueprintGroup]): Blueprints to be
registered as a group.
url_prefix (Optional[str]): URL route to be prepended to all
sub-prefixes. Default is `None`.
version (Optional[Union[int, str, float]]): API Version to be
used for Blueprint group. Default is `None`.
strict_slashes (Optional[bool]): Indicate strict slash
termination behavior for URL. Default is `None`.
version_prefix (str): Prefix to be used for the version in the
URL. Default is "/v".
name_prefix (Optional[str]): Prefix to be used for the name of
the blueprints in the group. Default is an empty string.
Returns:
BlueprintGroup: A group of blueprints.
Example:
The resulting group will have the URL prefixes
`'/v2/bp1'` and `'/v2/bp2'` for bp1 and bp2, respectively.
```python
bp1 = Blueprint('bp1', url_prefix='/bp1')
bp2 = Blueprint('bp2', url_prefix='/bp2')
group = group(bp1, bp2, version=2)
```
"""
def chain(nested) -> Iterable[Blueprint]:
"""Iterate through nested blueprints"""
for i in nested:
if isinstance(i, (list, tuple)):
yield from chain(i)
else:
yield i
bps = BlueprintGroup(
url_prefix=url_prefix,
version=version,
strict_slashes=strict_slashes,
version_prefix=version_prefix,
name_prefix=name_prefix,
)
for bp in chain(blueprints):
bps.append(bp)
return bps
def register(self, app, options):
"""Register the blueprint to the sanic app.
Args:
app (Sanic): Sanic app to register the blueprint to.
options (dict): Options to be passed to the blueprint.
"""
self._apps.add(app)
url_prefix = options.get("url_prefix", self.url_prefix)
opt_version = options.get("version", None)
opt_strict_slashes = options.get("strict_slashes", None)
opt_version_prefix = options.get("version_prefix", self.version_prefix)
opt_name_prefix = options.get("name_prefix", None)
error_format = options.get(
"error_format", app.config.FALLBACK_ERROR_FORMAT
)
routes = []
middleware = []
exception_handlers = []
listeners = defaultdict(list)
registered = set()
# Routes
for future in self._future_routes:
# Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix)
route_error_format = (
future.error_format if future.error_format else error_format
)
version_prefix = self.version_prefix
for prefix in (
future.version_prefix,
opt_version_prefix,
):
if prefix and prefix != "/v":
version_prefix = prefix
break
version = self._extract_value(
future.version, opt_version, self.version
)
strict_slashes = self._extract_value(
future.strict_slashes, opt_strict_slashes, self.strict_slashes
)
name = future.name
if opt_name_prefix:
name = f"{opt_name_prefix}_{future.name}"
name = app._generate_name(name)
host = future.host or self.host
if isinstance(host, list):
host = tuple(host)
apply_route = FutureRoute(
future.handler,
uri,
future.methods,
host,
strict_slashes,
future.stream,
version,
name,
future.ignore_body,
future.websocket,
future.subprotocols,
future.unquote,
future.static,
version_prefix,
route_error_format,
future.route_context,
)
if (self, apply_route) in app._future_registry:
continue
registered.add(apply_route)
route = app._apply_route(
apply_route, overwrite=self._allow_route_overwrite
)
# If it is a copied BP, then make sure all of the names of routes
# matchup with the new BP name
if self.copied_from:
for r in route:
r.name = r.name.replace(self.copied_from, self.name)
r.extra.ident = r.extra.ident.replace(
self.copied_from, self.name
)
operation = (
routes.extend if isinstance(route, list) else routes.append
)
operation(route)
# Static Files
for future in self._future_statics:
# Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix)
apply_route = FutureStatic(uri, *future[1:])
if (self, apply_route) in app._future_registry:
continue
registered.add(apply_route)
route = app._apply_static(apply_route)
routes.append(route)
route_names = [route.name for route in routes if route]
if route_names:
# Middleware
for future in self._future_middleware:
if (self, future) in app._future_registry:
continue
middleware.append(app._apply_middleware(future, route_names))
# Exceptions
for future in self._future_exceptions:
if (self, future) in app._future_registry:
continue
exception_handlers.append(
app._apply_exception_handler(future, route_names)
)
# Event listeners
for future in self._future_listeners:
if (self, future) in app._future_registry:
continue
listeners[future.event].append(app._apply_listener(future))
# Signals
for future in self._future_signals:
if (self, future) in app._future_registry:
continue
future.condition.update({"__blueprint__": self.name})
# Force exclusive to be False
app._apply_signal(tuple((*future[:-1], False)))
self.routes += [route for route in routes if isinstance(route, Route)]
self.websocket_routes += [
route for route in self.routes if route.extra.websocket
]
self.middlewares += middleware
self.exceptions += exception_handlers
self.listeners.update(dict(listeners))
if self.registered:
self.register_futures(
self.apps,
self,
chain(
registered,
self._future_middleware,
self._future_exceptions,
self._future_listeners,
self._future_signals,
),
)
async def dispatch(self, *args, **kwargs):
"""Dispatch a signal event
Args:
*args: Arguments to be passed to the signal event.
**kwargs: Keyword arguments to be passed to the signal event.
"""
condition = kwargs.pop("condition", {})
condition.update({"__blueprint__": self.name})
kwargs["condition"] = condition
await asyncio.gather(
*[app.dispatch(*args, **kwargs) for app in self.apps]
)
def event(self, event: str, timeout: Optional[Union[int, float]] = None):
"""Wait for a signal event to be dispatched.
Args:
event (str): Name of the signal event.
timeout (Optional[Union[int, float]]): Timeout for the event to be
dispatched.
Returns:
Awaitable: Awaitable for the event to be dispatched.
"""
events = set()
for app in self.apps:
signal = app.signal_router.name_index.get(event)
if not signal:
raise NotFound("Could not find signal %s" % event)
events.add(signal.ctx.event)
return asyncio.wait(
[asyncio.create_task(event.wait()) for event in events],
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout,
)
@staticmethod
def _extract_value(*values):
value = values[-1]
for v in values:
if v is not None:
value = v
break
return value
@staticmethod
def _setup_uri(base: str, prefix: Optional[str]):
uri = base
if prefix:
uri = prefix
if base.startswith("/") and prefix.endswith("/"):
uri += base[1:]
else:
uri += base
return uri[1:] if uri.startswith("//") else uri
@staticmethod
def register_futures(
apps: Set[Sanic], bp: Blueprint, futures: Sequence[Tuple[Any, ...]]
):
"""Register futures to the apps.
Args:
apps (Set[Sanic]): Set of apps to register the futures to.
bp (Blueprint): Blueprint that the futures belong to.
futures (Sequence[Tuple[Any, ...]]): Sequence of futures to be
registered.
"""
for app in apps:
app._future_registry.update(set((bp, item) for item in futures))
if sys.version_info < (3, 9):
bpg_base = MutableSequence
else:
bpg_base = MutableSequence[Blueprint]
class BlueprintGroup(bpg_base):
"""This class provides a mechanism to implement a Blueprint Group.
The `BlueprintGroup` class allows grouping blueprints under a common
URL prefix, version, and other shared attributes. It integrates with
Sanic's Blueprint system, offering a custom iterator to treat an
object of this class as a list/tuple.
Although possible to instantiate a group directly, it is recommended
to use the `Blueprint.group` method to create a group of blueprints.
Args:
url_prefix (Optional[str]): URL to be prefixed before all the
Blueprint Prefixes. Default is `None`.
version (Optional[Union[int, str, float]]): API Version for the
blueprint group, inherited by each Blueprint. Default is `None`.
strict_slashes (Optional[bool]): URL Strict slash behavior
indicator. Default is `None`.
version_prefix (str): Prefix for the version in the URL.
Default is `"/v"`.
name_prefix (Optional[str]): Prefix for the name of the blueprints
in the group. Default is an empty string.
Examples:
```python
bp1 = Blueprint("bp1", url_prefix="/bp1")
bp2 = Blueprint("bp2", url_prefix="/bp2")
bp3 = Blueprint("bp3", url_prefix="/bp4")
bp4 = Blueprint("bp3", url_prefix="/bp4")
group1 = Blueprint.group(bp1, bp2)
group2 = Blueprint.group(bp3, bp4, version_prefix="/api/v", version="1")
@bp1.on_request
async def bp1_only_middleware(request):
print("applied on Blueprint : bp1 Only")
@bp1.route("/")
async def bp1_route(request):
return text("bp1")
@bp2.route("/<param>")
async def bp2_route(request, param):
return text(param)
@bp3.route("/")
async def bp3_route(request):
return text("bp3")
@bp4.route("/<param>")
async def bp4_route(request, param):
return text(param)
@group1.on_request
async def group_middleware(request):
print("common middleware applied for both bp1 and bp2")
# Register Blueprint group under the app
app.blueprint(group1)
app.blueprint(group2)
```
""" # noqa: E501
__slots__ = (
"_blueprints",
"_url_prefix",
"_version",
"_strict_slashes",
"_version_prefix",
"_name_prefix",
)
def __init__(
self,
url_prefix: Optional[str] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
name_prefix: Optional[str] = "",
):
self._blueprints: List[Blueprint] = []
self._url_prefix = url_prefix
self._version = version
self._version_prefix = version_prefix
self._strict_slashes = strict_slashes
self._name_prefix = name_prefix
@property
def url_prefix(self) -> Optional[Union[int, str, float]]:
"""The URL prefix for the Blueprint Group.
Returns:
Optional[Union[int, str, float]]: URL prefix for the Blueprint
Group.
"""
return self._url_prefix
@property
def blueprints(self) -> List[Blueprint]:
"""A list of all the available blueprints under this group.
Returns:
List[Blueprint]: List of all the available blueprints under
this group.
"""
return self._blueprints
@property
def version(self) -> Optional[Union[str, int, float]]:
"""API Version for the Blueprint Group, if any.
Returns:
Optional[Union[str, int, float]]: API Version for the Blueprint
"""
return self._version
@property
def strict_slashes(self) -> Optional[bool]:
"""Whether to enforce strict slashes for the Blueprint Group.
Returns:
Optional[bool]: Whether to enforce strict slashes for the
"""
return self._strict_slashes
@property
def version_prefix(self) -> str:
"""Version prefix for the Blueprint Group.
Returns:
str: Version prefix for the Blueprint Group.
"""
return self._version_prefix
@property
def name_prefix(self) -> Optional[str]:
"""Name prefix for the Blueprint Group.
This is mainly needed when blueprints are copied in order to
avoid name conflicts.
Returns:
Optional[str]: Name prefix for the Blueprint Group.
"""
return self._name_prefix
def __iter__(self) -> Iterator[Blueprint]:
"""Iterate over the list of blueprints in the group.
Returns:
Iterator[Blueprint]: Iterator for the list of blueprints in
"""
return iter(self._blueprints)
@overload
def __getitem__(self, item: int) -> Blueprint:
...
@overload
def __getitem__(self, item: slice) -> MutableSequence[Blueprint]:
...
def __getitem__(
self, item: Union[int, slice]
) -> Union[Blueprint, MutableSequence[Blueprint]]:
"""Get the Blueprint object at the specified index.
This method returns a blueprint inside the group specified by
an index value. This will enable indexing, splice and slicing
of the blueprint group like we can do with regular list/tuple.
This method is provided to ensure backward compatibility with
any of the pre-existing usage that might break.
Returns:
Blueprint: Blueprint object at the specified index.
Raises:
IndexError: If the index is out of range.
"""
return self._blueprints[item]
@overload
def __setitem__(self, index: int, item: Blueprint) -> None:
...
@overload
def __setitem__(self, index: slice, item: Iterable[Blueprint]) -> None:
...
def __setitem__(
self,
index: Union[int, slice],
item: Union[Blueprint, Iterable[Blueprint]],
) -> None:
"""Set the Blueprint object at the specified index.
Abstract method implemented to turn the `BlueprintGroup` class
into a list like object to support all the existing behavior.
This method is used to perform the list's indexed setter operation.
Args:
index (int): Index to use for removing a new Blueprint item
item (Blueprint): New `Blueprint` object.
Returns:
None
Raises:
IndexError: If the index is out of range.
"""
if isinstance(index, int):
if not isinstance(item, Blueprint):
raise TypeError("Expected a Blueprint instance")
self._blueprints[index] = item
elif isinstance(index, slice):
if not isinstance(item, Iterable):
raise TypeError("Expected an iterable of Blueprint instances")
self._blueprints[index] = list(item)
else:
raise TypeError("Index must be int or slice")
@overload
def __delitem__(self, index: int) -> None:
...
@overload
def __delitem__(self, index: slice) -> None:
...
def __delitem__(self, index: Union[int, slice]) -> None:
"""Delete the Blueprint object at the specified index.
Abstract method implemented to turn the `BlueprintGroup` class
into a list like object to support all the existing behavior.
This method is used to delete an item from the list of blueprint
groups like it can be done on a regular list with index.
Args:
index (int): Index to use for removing a new Blueprint item
Returns:
None
Raises:
IndexError: If the index is out of range.
"""
del self._blueprints[index]
def __len__(self) -> int:
"""Get the Length of the blueprint group object.
Returns:
int: Length of the blueprint group object.
"""
return len(self._blueprints)
def append(self, value: Blueprint) -> None:
"""Add a new Blueprint object to the group.
The Abstract class `MutableSequence` leverages this append method to
perform the `BlueprintGroup.append` operation.
Args:
value (Blueprint): New `Blueprint` object.
Returns:
None
"""
self._blueprints.append(value)
def exception(self, *exceptions: Exception, **kwargs) -> Callable:
"""Decorate a function to handle exceptions for all blueprints in the group.
In case of nested Blueprint Groups, the same handler is applied
across each of the Blueprints recursively.
Args:
*exceptions (Exception): Exceptions to handle
**kwargs (dict): Optional Keyword arg to use with Middleware
Returns:
Partial function to apply the middleware
Examples:
```python
bp1 = Blueprint("bp1", url_prefix="/bp1")
bp2 = Blueprint("bp2", url_prefix="/bp2")
group1 = Blueprint.group(bp1, bp2)
@group1.exception(Exception)
def handler(request, exception):
return text("Exception caught")
```
""" # noqa: E501
def register_exception_handler_for_blueprints(fn):
for blueprint in self.blueprints:
blueprint.exception(*exceptions, **kwargs)(fn)
return register_exception_handler_for_blueprints
def insert(self, index: int, item: Blueprint) -> None:
"""Insert a new Blueprint object to the group at the specified index.
The Abstract class `MutableSequence` leverages this insert method to
perform the `BlueprintGroup.append` operation.
Args:
index (int): Index to use for removing a new Blueprint item
item (Blueprint): New `Blueprint` object.
Returns:
None
"""
self._blueprints.insert(index, item)
def middleware(self, *args, **kwargs):
"""A decorator that can be used to implement a Middleware for all blueprints in the group.
In case of nested Blueprint Groups, the same middleware is applied
across each of the Blueprints recursively.
Args:
*args (Optional): Optional positional Parameters to be use middleware
**kwargs (Optional): Optional Keyword arg to use with Middleware
Returns:
Partial function to apply the middleware
""" # noqa: E501
def register_middleware_for_blueprints(fn):
for blueprint in self.blueprints:
blueprint.middleware(fn, *args, **kwargs)
if args and callable(args[0]):
fn = args[0]
args = list(args)[1:]
return register_middleware_for_blueprints(fn)
return register_middleware_for_blueprints
def on_request(self, middleware=None):
"""Convenience method to register a request middleware for all blueprints in the group.
Args:
middleware (Optional): Optional positional Parameters to be use middleware
Returns:
Partial function to apply the middleware
""" # noqa: E501
if callable(middleware):
return self.middleware(middleware, "request")
else:
return partial(self.middleware, attach_to="request")
def on_response(self, middleware=None):
"""Convenience method to register a response middleware for all blueprints in the group.
Args:
middleware (Optional): Optional positional Parameters to be use middleware
Returns:
Partial function to apply the middleware
""" # noqa: E501
if callable(middleware):
return self.middleware(middleware, "response")
else:
return partial(self.middleware, attach_to="response")