chore: rename project coven → hack-house ⛧
Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"), README, CLI strings, and branch (coven → hack-house). Gitea repo renamed cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest + golden-vector test still green, binary is now `hack-house`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
|
||||
|
||||
class NameProtocol(Protocol):
|
||||
name: str
|
||||
|
||||
|
||||
class DunderNameProtocol(Protocol):
|
||||
__name__: str
|
||||
|
||||
|
||||
class BaseMixin(metaclass=SanicMeta):
|
||||
"""Base class for various mixins."""
|
||||
|
||||
name: str
|
||||
strict_slashes: bool | None
|
||||
|
||||
def _generate_name(
|
||||
self, *objects: NameProtocol | DunderNameProtocol | str
|
||||
) -> str:
|
||||
name: str | None = None
|
||||
for obj in objects:
|
||||
if not obj:
|
||||
continue
|
||||
if isinstance(obj, str):
|
||||
name = obj
|
||||
else:
|
||||
name = getattr(obj, "name", getattr(obj, "__name__", None))
|
||||
|
||||
if name:
|
||||
break
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Could not generate a name for handler")
|
||||
|
||||
if not name.startswith(f"{self.name}."):
|
||||
name = f"{self.name}.{name}"
|
||||
|
||||
return name
|
||||
|
||||
def generate_name(self, *objects) -> str:
|
||||
return self._generate_name(*objects)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
from inspect import isawaitable
|
||||
from typing import Callable
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.models.futures import FutureCommand
|
||||
|
||||
|
||||
class CommandMixin(metaclass=SanicMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_commands: set[FutureCommand] = set()
|
||||
|
||||
def command(
|
||||
self, maybe_func: Callable | None = None, *, name: str = ""
|
||||
) -> Callable | Callable[[Callable], Callable]:
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated_function(*args, **kwargs):
|
||||
response = f(*args, **kwargs)
|
||||
if isawaitable(response):
|
||||
response = await response
|
||||
return response
|
||||
|
||||
self._future_commands.add(
|
||||
FutureCommand(name or f.__name__, decorated_function)
|
||||
)
|
||||
return decorated_function
|
||||
|
||||
return decorator(maybe_func) if maybe_func else decorator
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.models.futures import FutureException
|
||||
|
||||
|
||||
class ExceptionMixin(metaclass=SanicMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_exceptions: set[FutureException] = set()
|
||||
|
||||
def _apply_exception_handler(self, handler: FutureException):
|
||||
raise NotImplementedError # noqa
|
||||
|
||||
def exception(
|
||||
self,
|
||||
*exceptions: type[Exception] | list[type[Exception]],
|
||||
apply: bool = True,
|
||||
) -> Callable:
|
||||
"""Decorator used to register an exception handler for the current application or blueprint instance.
|
||||
|
||||
This method allows you to define a handler for specific exceptions that
|
||||
may be raised within the routes of this blueprint. You can specify one
|
||||
or more exception types to catch, and the handler will be applied to
|
||||
those exceptions.
|
||||
|
||||
When used on a Blueprint, the handler will only be applied to routes
|
||||
registered under that blueprint. That means they only apply to
|
||||
requests that have been matched, and the exception is raised within
|
||||
the handler function (or middleware) for that route.
|
||||
|
||||
A general exception like `NotFound` should only be registered on the
|
||||
application instance, not on a blueprint.
|
||||
|
||||
See [Exceptions](/en/guide/best-practices/exceptions.html) for more information.
|
||||
|
||||
Args:
|
||||
exceptions (Union[Type[Exception], List[Type[Exception]]]): List of
|
||||
Python exceptions to be caught by the handler.
|
||||
apply (bool, optional): Whether the exception handler should be
|
||||
applied. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Callable: A decorated method to handle global exceptions for any route
|
||||
registered under this blueprint.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from sanic import Blueprint, text
|
||||
|
||||
bp = Blueprint('my_blueprint')
|
||||
|
||||
@bp.exception(Exception)
|
||||
def handle_exception(request, exception):
|
||||
return text("Oops, something went wrong!", status=500)
|
||||
```
|
||||
|
||||
```python
|
||||
from sanic import Sanic, NotFound, text
|
||||
|
||||
app = Sanic('MyApp')
|
||||
|
||||
@app.exception(NotFound)
|
||||
def ignore_404s(request, exception):
|
||||
return text(f"Yep, I totally found the page: {request.url}")
|
||||
""" # noqa: E501
|
||||
|
||||
def decorator(handler):
|
||||
nonlocal apply
|
||||
nonlocal exceptions
|
||||
|
||||
if isinstance(exceptions[0], list):
|
||||
exceptions = tuple(*exceptions)
|
||||
|
||||
future_exception = FutureException(handler, exceptions)
|
||||
self._future_exceptions.add(future_exception)
|
||||
if apply:
|
||||
self._apply_exception_handler(future_exception)
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
def all_exceptions(
|
||||
self, handler: Callable[..., Any]
|
||||
) -> Callable[..., Any]:
|
||||
"""Enables the process of creating a global exception handler as a convenience.
|
||||
|
||||
This following two examples are equivalent:
|
||||
|
||||
```python
|
||||
@app.exception(Exception)
|
||||
async def handler(request: Request, exception: Exception) -> HTTPResponse:
|
||||
return text(f"Exception raised: {exception}")
|
||||
```
|
||||
|
||||
```python
|
||||
@app.all_exceptions
|
||||
async def handler(request: Request, exception: Exception) -> HTTPResponse:
|
||||
return text(f"Exception raised: {exception}")
|
||||
```
|
||||
|
||||
Args:
|
||||
handler (Callable[..., Any]): A coroutine function to handle exceptions.
|
||||
|
||||
Returns:
|
||||
Callable[..., Any]: A decorated method to handle global exceptions for
|
||||
any route registered under this blueprint.
|
||||
""" # noqa: E501
|
||||
return self.exception(Exception)(handler)
|
||||
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, auto
|
||||
from functools import partial
|
||||
from typing import Callable, cast, overload
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.exceptions import BadRequest
|
||||
from sanic.models.futures import FutureListener
|
||||
from sanic.models.handler_types import ListenerType, Sanic
|
||||
|
||||
|
||||
class ListenerEvent(str, Enum):
|
||||
def _generate_next_value_(name: str, *args) -> str: # type: ignore
|
||||
return name.lower()
|
||||
|
||||
BEFORE_SERVER_START = "server.init.before"
|
||||
AFTER_SERVER_START = "server.init.after"
|
||||
BEFORE_SERVER_STOP = "server.shutdown.before"
|
||||
AFTER_SERVER_STOP = "server.shutdown.after"
|
||||
MAIN_PROCESS_START = auto()
|
||||
MAIN_PROCESS_READY = auto()
|
||||
MAIN_PROCESS_STOP = auto()
|
||||
RELOAD_PROCESS_START = auto()
|
||||
RELOAD_PROCESS_STOP = auto()
|
||||
BEFORE_RELOAD_TRIGGER = auto()
|
||||
AFTER_RELOAD_TRIGGER = auto()
|
||||
|
||||
|
||||
class ListenerMixin(metaclass=SanicMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_listeners: list[FutureListener] = []
|
||||
|
||||
def _apply_listener(self, listener: FutureListener):
|
||||
raise NotImplementedError # noqa
|
||||
|
||||
@overload
|
||||
def listener(
|
||||
self,
|
||||
listener_or_event: ListenerType[Sanic],
|
||||
event_or_none: str,
|
||||
apply: bool = ...,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> ListenerType[Sanic]: ...
|
||||
|
||||
@overload
|
||||
def listener(
|
||||
self,
|
||||
listener_or_event: str,
|
||||
event_or_none: None = ...,
|
||||
apply: bool = ...,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> Callable[[ListenerType[Sanic]], ListenerType[Sanic]]: ...
|
||||
|
||||
def listener(
|
||||
self,
|
||||
listener_or_event: ListenerType[Sanic] | str,
|
||||
event_or_none: str | None = None,
|
||||
apply: bool = True,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> (
|
||||
ListenerType[Sanic]
|
||||
| Callable[[ListenerType[Sanic]], ListenerType[Sanic]]
|
||||
):
|
||||
"""Create a listener for a specific event in the application's lifecycle.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
.. note::
|
||||
Overloaded signatures allow for different ways of calling this method, depending on the types of the arguments.
|
||||
|
||||
Usually, it is prederred to use one of the convenience methods such as `before_server_start` or `after_server_stop` instead of calling this method directly.
|
||||
|
||||
```python
|
||||
@app.before_server_start
|
||||
async def prefered_method(_):
|
||||
...
|
||||
|
||||
@app.listener("before_server_start")
|
||||
async def not_prefered_method(_):
|
||||
...
|
||||
|
||||
Args:
|
||||
listener_or_event (Union[ListenerType[Sanic], str]): A listener function or an event name.
|
||||
event_or_none (Optional[str]): The event name to listen for if `listener_or_event` is a function. Defaults to `None`.
|
||||
apply (bool): Whether to apply the listener immediately. Defaults to `True`.
|
||||
priority (int): The priority of the listener. Defaults to `0`.
|
||||
|
||||
Returns:
|
||||
Union[ListenerType[Sanic], Callable[[ListenerType[Sanic]], ListenerType[Sanic]]]: The listener or a callable that takes a listener.
|
||||
|
||||
Example:
|
||||
The following code snippet shows how you can use this method as a decorator:
|
||||
|
||||
```python
|
||||
@bp.listener("before_server_start")
|
||||
async def before_server_start(app, loop):
|
||||
...
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
def register_listener(
|
||||
listener: ListenerType[Sanic], event: str, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""A helper function to register a listener for an event.
|
||||
|
||||
Typically will not be called directly.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener function to
|
||||
register.
|
||||
event (str): The event name to listen for.
|
||||
|
||||
Returns:
|
||||
ListenerType[Sanic]: The listener function that was registered.
|
||||
"""
|
||||
nonlocal apply
|
||||
|
||||
future_listener = FutureListener(listener, event, priority)
|
||||
self._future_listeners.append(future_listener)
|
||||
if apply:
|
||||
self._apply_listener(future_listener)
|
||||
return listener
|
||||
|
||||
if callable(listener_or_event):
|
||||
if event_or_none is None:
|
||||
raise BadRequest(
|
||||
"Invalid event registration: Missing event name."
|
||||
)
|
||||
return register_listener(
|
||||
listener_or_event, event_or_none, priority
|
||||
)
|
||||
else:
|
||||
return partial(
|
||||
register_listener, event=listener_or_event, priority=priority
|
||||
)
|
||||
|
||||
def _setup_listener(
|
||||
self,
|
||||
listener: ListenerType[Sanic] | None,
|
||||
event: str,
|
||||
priority: int,
|
||||
) -> ListenerType[Sanic]:
|
||||
if listener is not None:
|
||||
return self.listener(listener, event, priority=priority)
|
||||
return cast(
|
||||
ListenerType[Sanic],
|
||||
partial(self.listener, event_or_none=event, priority=priority),
|
||||
)
|
||||
|
||||
def main_process_start(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the main_process_start event.
|
||||
|
||||
This event is fired only on the main process and **NOT** on any
|
||||
worker processes. You should typically use this event to initialize
|
||||
resources that are shared across workers, or to initialize resources
|
||||
that are not safe to be initialized in a worker process.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.main_process_start
|
||||
async def on_main_process_start(app: Sanic):
|
||||
print("Main process started")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "main_process_start", priority)
|
||||
|
||||
def main_process_ready(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the main_process_ready event.
|
||||
|
||||
This event is fired only on the main process and **NOT** on any
|
||||
worker processes. It is fired after the main process has started and
|
||||
the Worker Manager has been initialized (ie, you will have access to
|
||||
`app.manager` instance). The typical use case for this event is to
|
||||
add a managed process to the Worker Manager.
|
||||
|
||||
See [Running custom processes](/en/guide/deployment/manager.html#running-custom-processes) and [Listeners](/en/guide/basics/listeners.html) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.main_process_ready
|
||||
async def on_main_process_ready(app: Sanic):
|
||||
print("Main process ready")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "main_process_ready", priority)
|
||||
|
||||
def main_process_stop(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the main_process_stop event.
|
||||
|
||||
This event is fired only on the main process and **NOT** on any
|
||||
worker processes. You should typically use this event to clean up
|
||||
resources that were initialized in the main_process_start event.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.main_process_stop
|
||||
async def on_main_process_stop(app: Sanic):
|
||||
print("Main process stopped")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "main_process_stop", priority)
|
||||
|
||||
def reload_process_start(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the reload_process_start event.
|
||||
|
||||
This event is fired only on the reload process and **NOT** on any
|
||||
worker processes. This is similar to the main_process_start event,
|
||||
except that it is fired only when the reload process is started.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.reload_process_start
|
||||
async def on_reload_process_start(app: Sanic):
|
||||
print("Reload process started")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "reload_process_start", priority)
|
||||
|
||||
def reload_process_stop(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the reload_process_stop event.
|
||||
|
||||
This event is fired only on the reload process and **NOT** on any
|
||||
worker processes. This is similar to the main_process_stop event,
|
||||
except that it is fired only when the reload process is stopped.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.reload_process_stop
|
||||
async def on_reload_process_stop(app: Sanic):
|
||||
print("Reload process stopped")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "reload_process_stop", priority)
|
||||
|
||||
def before_reload_trigger(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the before_reload_trigger event.
|
||||
|
||||
This event is fired only on the reload process and **NOT** on any
|
||||
worker processes. This event is fired before the reload process
|
||||
triggers the reload. A change event has been detected and the reload
|
||||
process is about to be triggered.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.before_reload_trigger
|
||||
async def on_before_reload_trigger(app: Sanic):
|
||||
print("Before reload trigger")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(
|
||||
listener, "before_reload_trigger", priority
|
||||
)
|
||||
|
||||
def after_reload_trigger(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the after_reload_trigger event.
|
||||
|
||||
This event is fired only on the reload process and **NOT** on any
|
||||
worker processes. This event is fired after the reload process
|
||||
triggers the reload. A change event has been detected and the reload
|
||||
process has been triggered.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.after_reload_trigger
|
||||
async def on_after_reload_trigger(app: Sanic, changed: set[str]):
|
||||
print("After reload trigger, changed files: ", changed)
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "after_reload_trigger", priority)
|
||||
|
||||
def before_server_start(
|
||||
self,
|
||||
listener: ListenerType[Sanic] | None = None,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the before_server_start event.
|
||||
|
||||
This event is fired on all worker processes. You should typically
|
||||
use this event to initialize resources that are global in nature, or
|
||||
will be shared across requests and various parts of the application.
|
||||
|
||||
A common use case for this event is to initialize a database connection
|
||||
pool, or to initialize a cache client.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.before_server_start
|
||||
async def on_before_server_start(app: Sanic):
|
||||
print("Before server start")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "before_server_start", priority)
|
||||
|
||||
def after_server_start(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the after_server_start event.
|
||||
|
||||
This event is fired on all worker processes. You should typically
|
||||
use this event to run background tasks, or perform other actions that
|
||||
are not directly related to handling requests. In theory, it is
|
||||
possible that some requests may be handled before this event is fired,
|
||||
so you should not use this event to initialize resources that are
|
||||
required for handling requests.
|
||||
|
||||
A common use case for this event is to start a background task that
|
||||
periodically performs some action, such as clearing a cache or
|
||||
performing a health check.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.after_server_start
|
||||
async def on_after_server_start(app: Sanic):
|
||||
print("After server start")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "after_server_start", priority)
|
||||
|
||||
def before_server_stop(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the before_server_stop event.
|
||||
|
||||
This event is fired on all worker processes. This event is fired
|
||||
before the server starts shutting down. You should not use this event
|
||||
to perform any actions that are required for handling requests, as
|
||||
some requests may continue to be handled after this event is fired.
|
||||
|
||||
A common use case for this event is to stop a background task that
|
||||
was started in the after_server_start event.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.before_server_stop
|
||||
async def on_before_server_stop(app: Sanic):
|
||||
print("Before server stop")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "before_server_stop", priority)
|
||||
|
||||
def after_server_stop(
|
||||
self, listener: ListenerType[Sanic] | None, *, priority: int = 0
|
||||
) -> ListenerType[Sanic]:
|
||||
"""Decorator for registering a listener for the after_server_stop event.
|
||||
|
||||
This event is fired on all worker processes. This event is fired
|
||||
after the server has stopped shutting down, and all requests have
|
||||
been handled. You should typically use this event to clean up
|
||||
resources that were initialized in the before_server_start event.
|
||||
|
||||
A common use case for this event is to close a database connection
|
||||
pool, or to close a cache client.
|
||||
|
||||
See [Listeners](/en/guide/basics/listeners) for more details.
|
||||
|
||||
Args:
|
||||
listener (ListenerType[Sanic]): The listener handler to attach.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.after_server_stop
|
||||
async def on_after_server_stop(app: Sanic):
|
||||
print("After server stop")
|
||||
```
|
||||
""" # noqa: E501
|
||||
return self._setup_listener(listener, "after_server_stop", priority)
|
||||
@@ -0,0 +1,232 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
from operator import attrgetter
|
||||
from typing import Callable, overload
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.middleware import Middleware, MiddlewareLocation
|
||||
from sanic.models.futures import FutureMiddleware, MiddlewareType
|
||||
from sanic.router import Router
|
||||
|
||||
|
||||
class MiddlewareMixin(metaclass=SanicMeta):
|
||||
router: Router
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_middleware: list[FutureMiddleware] = []
|
||||
|
||||
def _apply_middleware(self, middleware: FutureMiddleware):
|
||||
raise NotImplementedError # noqa
|
||||
|
||||
@overload
|
||||
def middleware(
|
||||
self,
|
||||
middleware_or_request: MiddlewareType,
|
||||
attach_to: str = "request",
|
||||
apply: bool = True,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> MiddlewareType: ...
|
||||
|
||||
@overload
|
||||
def middleware(
|
||||
self,
|
||||
middleware_or_request: str,
|
||||
attach_to: str = "request",
|
||||
apply: bool = True,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> Callable[[MiddlewareType], MiddlewareType]: ...
|
||||
|
||||
def middleware(
|
||||
self,
|
||||
middleware_or_request: MiddlewareType | str,
|
||||
attach_to: str = "request",
|
||||
apply: bool = True,
|
||||
*,
|
||||
priority: int = 0,
|
||||
) -> MiddlewareType | Callable[[MiddlewareType], MiddlewareType]:
|
||||
"""Decorator for registering middleware.
|
||||
|
||||
Decorate and register middleware to be called before a request is
|
||||
handled or after a response is created. Can either be called as
|
||||
*@app.middleware* or *@app.middleware('request')*. Although, it is
|
||||
recommended to use *@app.on_request* or *@app.on_response* instead
|
||||
for clarity and convenience.
|
||||
|
||||
See [Middleware](/guide/basics/middleware) for more information.
|
||||
|
||||
Args:
|
||||
middleware_or_request (Union[Callable, str]): Middleware function
|
||||
or the keyword 'request' or 'response'.
|
||||
attach_to (str, optional): When to apply the middleware;
|
||||
either 'request' (before the request is handled) or 'response'
|
||||
(after the response is created). Defaults to `'request'`.
|
||||
apply (bool, optional): Whether the middleware should be applied.
|
||||
Defaults to `True`.
|
||||
priority (int, optional): The priority level of the middleware.
|
||||
Lower numbers are executed first. Defaults to `0`.
|
||||
|
||||
Returns:
|
||||
Union[Callable, Callable[[Callable], Callable]]: The decorated
|
||||
middleware function or a partial function depending on how
|
||||
the method was called.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@app.middleware('request')
|
||||
async def custom_middleware(request):
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
def register_middleware(middleware, attach_to="request"):
|
||||
nonlocal apply
|
||||
|
||||
location = (
|
||||
MiddlewareLocation.REQUEST
|
||||
if attach_to == "request"
|
||||
else MiddlewareLocation.RESPONSE
|
||||
)
|
||||
middleware = Middleware(middleware, location, priority=priority)
|
||||
future_middleware = FutureMiddleware(middleware, attach_to)
|
||||
self._future_middleware.append(future_middleware)
|
||||
if apply:
|
||||
self._apply_middleware(future_middleware)
|
||||
return middleware
|
||||
|
||||
# Detect which way this was called, @middleware or @middleware('AT')
|
||||
if callable(middleware_or_request):
|
||||
return register_middleware(
|
||||
middleware_or_request, attach_to=attach_to
|
||||
)
|
||||
else:
|
||||
return partial(
|
||||
register_middleware, attach_to=middleware_or_request
|
||||
)
|
||||
|
||||
def on_request(self, middleware=None, *, priority=0) -> MiddlewareType:
|
||||
"""Register a middleware to be called before a request is handled.
|
||||
|
||||
This is the same as *@app.middleware('request')*.
|
||||
|
||||
Args:
|
||||
middleware (Callable, optional): A callable that takes in a
|
||||
request. Defaults to `None`.
|
||||
|
||||
Returns:
|
||||
Callable: The decorated middleware function or a partial function
|
||||
depending on how the method was called.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.on_request
|
||||
async def custom_middleware(request):
|
||||
request.ctx.custom = 'value'
|
||||
```
|
||||
"""
|
||||
if callable(middleware):
|
||||
return self.middleware(middleware, "request", priority=priority)
|
||||
else:
|
||||
return partial( # type: ignore
|
||||
self.middleware, attach_to="request", priority=priority
|
||||
)
|
||||
|
||||
def on_response(self, middleware=None, *, priority=0):
|
||||
"""Register a middleware to be called after a response is created.
|
||||
|
||||
This is the same as *@app.middleware('response')*.
|
||||
|
||||
Args:
|
||||
middleware (Callable, optional): A callable that takes in a
|
||||
request and response. Defaults to `None`.
|
||||
|
||||
Returns:
|
||||
Callable: The decorated middleware function or a partial function
|
||||
depending on how the method was called.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
@app.on_response
|
||||
async def custom_middleware(request, response):
|
||||
response.headers['X-Server'] = 'Sanic'
|
||||
```
|
||||
"""
|
||||
if callable(middleware):
|
||||
return self.middleware(middleware, "response", priority=priority)
|
||||
else:
|
||||
return partial(
|
||||
self.middleware, attach_to="response", priority=priority
|
||||
)
|
||||
|
||||
def finalize_middleware(self) -> None:
|
||||
"""Finalize the middleware configuration for the Sanic application.
|
||||
|
||||
This method completes the middleware setup for the application.
|
||||
Middleware in Sanic is used to process requests globally before they
|
||||
reach individual routes or after routes have been processed.
|
||||
|
||||
Finalization consists of identifying defined routes and optimizing
|
||||
Sanic's performance to meet the application's specific needs. If
|
||||
you are manually adding routes, after Sanic has started, you will
|
||||
typically want to use the `amend` context manager rather than
|
||||
calling this method directly.
|
||||
|
||||
.. note::
|
||||
This method is usually called internally during the server setup
|
||||
process and does not typically need to be invoked manually.
|
||||
|
||||
Example:
|
||||
```python
|
||||
app.finalize_middleware()
|
||||
```
|
||||
"""
|
||||
for route in self.router.routes:
|
||||
request_middleware = Middleware.convert(
|
||||
self.request_middleware, # type: ignore
|
||||
self.named_request_middleware.get(route.name, deque()), # type: ignore # noqa: E501
|
||||
location=MiddlewareLocation.REQUEST,
|
||||
)
|
||||
response_middleware = Middleware.convert(
|
||||
self.response_middleware, # type: ignore
|
||||
self.named_response_middleware.get(route.name, deque()), # type: ignore # noqa: E501
|
||||
location=MiddlewareLocation.RESPONSE,
|
||||
)
|
||||
route.extra.request_middleware = deque(
|
||||
sorted(
|
||||
request_middleware,
|
||||
key=attrgetter("order"),
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
route.extra.response_middleware = deque(
|
||||
sorted(
|
||||
response_middleware,
|
||||
key=attrgetter("order"),
|
||||
reverse=True,
|
||||
)[::-1]
|
||||
)
|
||||
request_middleware = Middleware.convert(
|
||||
self.request_middleware, # type: ignore
|
||||
location=MiddlewareLocation.REQUEST,
|
||||
)
|
||||
response_middleware = Middleware.convert(
|
||||
self.response_middleware, # type: ignore
|
||||
location=MiddlewareLocation.RESPONSE,
|
||||
)
|
||||
self.request_middleware = deque(
|
||||
sorted(
|
||||
request_middleware,
|
||||
key=attrgetter("order"),
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
self.response_middleware = deque(
|
||||
sorted(
|
||||
response_middleware,
|
||||
key=attrgetter("order"),
|
||||
reverse=True,
|
||||
)[::-1]
|
||||
)
|
||||
@@ -0,0 +1,817 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ast import NodeVisitor, Return, parse
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from inspect import getsource, signature
|
||||
from textwrap import dedent
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
cast,
|
||||
)
|
||||
|
||||
from sanic_routing.route import Route
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.constants import HTTP_METHODS
|
||||
from sanic.errorpages import RESPONSE_MAPPING
|
||||
from sanic.mixins.base import BaseMixin
|
||||
from sanic.models.futures import FutureRoute, FutureStatic
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
from sanic.types import HashableDict
|
||||
|
||||
|
||||
RouteWrapper = Callable[
|
||||
[RouteHandler], RouteHandler | tuple[Route, RouteHandler]
|
||||
]
|
||||
|
||||
|
||||
class RouteMixin(BaseMixin, metaclass=SanicMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_routes: set[FutureRoute] = set()
|
||||
self._future_statics: set[FutureStatic] = set()
|
||||
|
||||
def _apply_route(self, route: FutureRoute) -> list[Route]:
|
||||
raise NotImplementedError # noqa
|
||||
|
||||
def route(
|
||||
self,
|
||||
uri: str,
|
||||
methods: Iterable[str] | None = None,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
stream: bool = False,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
ignore_body: bool = False,
|
||||
apply: bool = True,
|
||||
subprotocols: list[str] | None = None,
|
||||
websocket: bool = False,
|
||||
unquote: bool = False,
|
||||
static: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteWrapper:
|
||||
"""Decorate a function to be registered as a route.
|
||||
|
||||
Args:
|
||||
uri (str): Path of the URL.
|
||||
methods (Optional[Iterable[str]]): List or tuple of
|
||||
methods allowed.
|
||||
host (Optional[Union[str, List[str]]]): The host, if required.
|
||||
strict_slashes (Optional[bool]): Whether to apply strict slashes
|
||||
to the route.
|
||||
stream (bool): Whether to allow the request to stream its body.
|
||||
version (Optional[Union[int, str, float]]): Route specific
|
||||
versioning.
|
||||
name (Optional[str]): User-defined route name for url_for.
|
||||
ignore_body (bool): Whether the handler should ignore request
|
||||
body (e.g. `GET` requests).
|
||||
apply (bool): Apply middleware to the route.
|
||||
subprotocols (Optional[List[str]]): List of subprotocols.
|
||||
websocket (bool): Enable WebSocket support.
|
||||
unquote (bool): Unquote special characters in the URL path.
|
||||
static (bool): Enable static route.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value; default: `"/v"`.
|
||||
error_format (Optional[str]): Error format for the route.
|
||||
ctx_kwargs (Any): Keyword arguments that begin with a `ctx_*`
|
||||
prefix will be appended to the route context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteWrapper: Tuple of routes, decorated function.
|
||||
|
||||
Examples:
|
||||
Using the method to define a GET endpoint:
|
||||
|
||||
```python
|
||||
@app.route("/hello")
|
||||
async def hello(request: Request):
|
||||
return text("Hello, World!")
|
||||
```
|
||||
|
||||
Adding context kwargs to the route:
|
||||
|
||||
```python
|
||||
@app.route("/greet", ctx_name="World")
|
||||
async def greet(request: Request):
|
||||
name = request.route.ctx.name
|
||||
return text(f"Hello, {name}!")
|
||||
```
|
||||
"""
|
||||
|
||||
# Fix case where the user did not prefix the URL with a /
|
||||
# and will probably get confused as to why it's not working
|
||||
if not uri.startswith("/") and (uri or hasattr(self, "router")):
|
||||
uri = "/" + uri
|
||||
|
||||
if strict_slashes is None:
|
||||
strict_slashes = self.strict_slashes
|
||||
|
||||
if not methods and not websocket:
|
||||
methods = frozenset({"GET"})
|
||||
|
||||
route_context = self._build_route_context(ctx_kwargs)
|
||||
|
||||
def decorator(handler):
|
||||
nonlocal uri
|
||||
nonlocal methods
|
||||
nonlocal host
|
||||
nonlocal strict_slashes
|
||||
nonlocal stream
|
||||
nonlocal version
|
||||
nonlocal name
|
||||
nonlocal ignore_body
|
||||
nonlocal subprotocols
|
||||
nonlocal websocket
|
||||
nonlocal static
|
||||
nonlocal version_prefix
|
||||
nonlocal error_format
|
||||
|
||||
if isinstance(handler, tuple):
|
||||
# if a handler fn is already wrapped in a route, the handler
|
||||
# variable will be a tuple of (existing routes, handler fn)
|
||||
_, handler = handler
|
||||
|
||||
name = self.generate_name(name, handler)
|
||||
|
||||
if isinstance(host, str):
|
||||
host = frozenset([host])
|
||||
elif host and not isinstance(host, frozenset):
|
||||
try:
|
||||
host = frozenset(host)
|
||||
except TypeError:
|
||||
raise ValueError(
|
||||
"Expected either string or Iterable of host strings, "
|
||||
"not %s" % host
|
||||
)
|
||||
if isinstance(subprotocols, list):
|
||||
# Ordered subprotocols, maintain order
|
||||
subprotocols = tuple(subprotocols)
|
||||
elif isinstance(subprotocols, set):
|
||||
# subprotocol is unordered, keep it unordered
|
||||
subprotocols = frozenset(subprotocols)
|
||||
|
||||
if not error_format or error_format == "auto":
|
||||
error_format = self._determine_error_format(handler)
|
||||
|
||||
route = FutureRoute(
|
||||
handler,
|
||||
uri,
|
||||
None if websocket else frozenset([x.upper() for x in methods]),
|
||||
host,
|
||||
strict_slashes,
|
||||
stream,
|
||||
version,
|
||||
name,
|
||||
ignore_body,
|
||||
websocket,
|
||||
subprotocols,
|
||||
unquote,
|
||||
static,
|
||||
version_prefix,
|
||||
error_format,
|
||||
route_context,
|
||||
)
|
||||
overwrite = getattr(self, "_allow_route_overwrite", False)
|
||||
if overwrite:
|
||||
self._future_routes = set(
|
||||
filter(lambda x: x.uri != uri, self._future_routes)
|
||||
)
|
||||
self._future_routes.add(route)
|
||||
|
||||
args = list(signature(handler).parameters.keys())
|
||||
if websocket and len(args) < 2:
|
||||
handler_name = handler.__name__
|
||||
|
||||
raise ValueError(
|
||||
f"Required parameter `request` and/or `ws` missing "
|
||||
f"in the {handler_name}() route?"
|
||||
)
|
||||
elif not args:
|
||||
handler_name = handler.__name__
|
||||
|
||||
raise ValueError(
|
||||
f"Required parameter `request` missing "
|
||||
f"in the {handler_name}() route?"
|
||||
)
|
||||
|
||||
if not websocket and stream:
|
||||
handler.is_stream = stream
|
||||
|
||||
if apply:
|
||||
self._apply_route(route, overwrite=overwrite)
|
||||
|
||||
if static:
|
||||
return route, handler
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
handler: RouteHandler,
|
||||
uri: str,
|
||||
methods: Iterable[str] = frozenset({"GET"}),
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
stream: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
unquote: bool = False,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""A helper method to register class-based view or functions as a handler to the application url routes.
|
||||
|
||||
Args:
|
||||
handler (RouteHandler): Function or class-based view used as a route handler.
|
||||
uri (str): Path of the URL.
|
||||
methods (Iterable[str]): List or tuple of methods allowed; these are overridden if using an HTTPMethodView.
|
||||
host (Optional[Union[str, List[str]]]): Hostname or hostnames to match for this route.
|
||||
strict_slashes (Optional[bool]): If set, a route's slashes will be strict. E.g. `/foo` will not match `/foo/`.
|
||||
version (Optional[Union[int, str, float]]): Version of the API for this route.
|
||||
name (Optional[str]): User-defined route name for `url_for`.
|
||||
stream (bool): Boolean specifying if the handler is a stream handler.
|
||||
version_prefix (str): URL path that should be before the version value; default: ``/v``.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
unquote (bool): Boolean specifying if the handler requires unquoting.
|
||||
ctx_kwargs (Any): Keyword arguments that begin with a `ctx_*` prefix will be appended to the route context (``route.ctx``). See below for examples.
|
||||
|
||||
Returns:
|
||||
RouteHandler: The route handler.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
from sanic import Sanic, text
|
||||
|
||||
app = Sanic("test")
|
||||
|
||||
async def handler(request):
|
||||
return text("OK")
|
||||
|
||||
app.add_route(handler, "/test", methods=["GET", "POST"])
|
||||
```
|
||||
|
||||
You can use `ctx_kwargs` to add custom context to the route. This
|
||||
can often be useful when wanting to add metadata to a route that
|
||||
can be used by other parts of the application (like middleware).
|
||||
|
||||
```python
|
||||
from sanic import Sanic, text
|
||||
|
||||
app = Sanic("test")
|
||||
|
||||
async def handler(request):
|
||||
return text("OK")
|
||||
|
||||
async def custom_middleware(request):
|
||||
if request.route.ctx.monitor:
|
||||
do_some_monitoring()
|
||||
|
||||
app.add_route(handler, "/test", methods=["GET", "POST"], ctx_monitor=True)
|
||||
app.register_middleware(custom_middleware)
|
||||
""" # noqa: E501
|
||||
# Handle HTTPMethodView differently
|
||||
if hasattr(handler, "view_class"):
|
||||
methods = set()
|
||||
|
||||
for method in HTTP_METHODS:
|
||||
view_class = getattr(handler, "view_class")
|
||||
_handler = getattr(view_class, method.lower(), None)
|
||||
if _handler:
|
||||
methods.add(method)
|
||||
if hasattr(_handler, "is_stream"):
|
||||
stream = True
|
||||
|
||||
if strict_slashes is None:
|
||||
strict_slashes = self.strict_slashes
|
||||
|
||||
self.route(
|
||||
uri=uri,
|
||||
methods=methods,
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
stream=stream,
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
unquote=unquote,
|
||||
**ctx_kwargs,
|
||||
)(handler)
|
||||
return handler
|
||||
|
||||
# Shorthand method decorators
|
||||
def get(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **GET** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to GET method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for
|
||||
the service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a `/`.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the route.
|
||||
ignore_body (bool): Whether the handler should ignore request
|
||||
body. This means the body of the request, if sent, will not
|
||||
be consumed. In that instance, you will see a warning in
|
||||
the logs. Defaults to `True`, meaning do not consume the body.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a
|
||||
`ctx_* prefix` will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"GET"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def post(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
stream: bool = False,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **POST** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to POST method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for
|
||||
the service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a `/`.
|
||||
stream (bool): Whether or not to stream the request body.
|
||||
Defaults to `False`.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the route.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a
|
||||
`ctx_*` prefix will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"POST"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
stream=stream,
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
stream: bool = False,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **PUT** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to PUT method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for
|
||||
the service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a `/`.
|
||||
stream (bool): Whether or not to stream the request body.
|
||||
Defaults to `False`.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the route.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a
|
||||
`ctx_*` prefix will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"PUT"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
stream=stream,
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def head(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **HEAD** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to HEAD method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for
|
||||
the service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a `/`.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the route.
|
||||
ignore_body (bool): Whether the handler should ignore request
|
||||
body. This means the body of the request, if sent, will not
|
||||
be consumed. In that instance, you will see a warning in
|
||||
the logs. Defaults to `True`, meaning do not consume the body.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a
|
||||
`ctx_*` prefix will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"HEAD"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def options(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **OPTIONS** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to OPTIONS method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for
|
||||
the service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a `/`.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the route.
|
||||
ignore_body (bool): Whether the handler should ignore request
|
||||
body. This means the body of the request, if sent, will not
|
||||
be consumed. In that instance, you will see a warning in
|
||||
the logs. Defaults to `True`, meaning do not consume the body.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a
|
||||
`ctx_*` prefix will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"OPTIONS"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def patch(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
stream=False,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **PATCH** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to PATCH method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for
|
||||
the service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a `/`.
|
||||
stream (bool): Set to `True` if full request streaming is needed,
|
||||
`False` otherwise. Defaults to `False`.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the route.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a
|
||||
`ctx_*` prefix will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"PATCH"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
stream=stream,
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
ignore_body: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
) -> RouteHandler:
|
||||
"""Decorate a function handler to create a route definition using the **DELETE** HTTP method.
|
||||
|
||||
Args:
|
||||
uri (str): URL to be tagged to the DELETE method of HTTP.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN for the
|
||||
service to use.
|
||||
strict_slashes (Optional[bool]): Instruct Sanic to check if the
|
||||
request URLs need to terminate with a */*.
|
||||
version (Optional[Union[int, str, float]]): API Version.
|
||||
name (Optional[str]): Unique name that can be used to identify
|
||||
the Route.
|
||||
ignore_body (bool): Whether or not to ignore the body in the
|
||||
request. Defaults to `False`.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with a `ctx_*`
|
||||
prefix will be appended to the route context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
RouteHandler: Object decorated with route method.
|
||||
""" # noqa: E501
|
||||
return cast(
|
||||
RouteHandler,
|
||||
self.route(
|
||||
uri,
|
||||
methods=frozenset({"DELETE"}),
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
def websocket(
|
||||
self,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
subprotocols: list[str] | None = None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
apply: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
):
|
||||
"""Decorate a function to be registered as a websocket route.
|
||||
|
||||
Args:
|
||||
uri (str): Path of the URL.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN details.
|
||||
strict_slashes (Optional[bool]): If the API endpoint needs to
|
||||
terminate with a `"/"` or not.
|
||||
subprotocols (Optional[List[str]]): Optional list of str with
|
||||
supported subprotocols.
|
||||
version (Optional[Union[int, str, float]]): WebSocket
|
||||
protocol version.
|
||||
name (Optional[str]): A unique name assigned to the URL so that
|
||||
it can be used with url_for.
|
||||
apply (bool): If set to False, it doesn't apply the route to the
|
||||
app. Default is `True`.
|
||||
version_prefix (str): URL path that should be before the version
|
||||
value. Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Custom error format string.
|
||||
**ctx_kwargs (Any): Keyword arguments that begin with
|
||||
a `ctx_* prefix` will be appended to the route
|
||||
context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
tuple: Tuple of routes, decorated function.
|
||||
"""
|
||||
return self.route(
|
||||
uri=uri,
|
||||
host=host,
|
||||
methods=None,
|
||||
strict_slashes=strict_slashes,
|
||||
version=version,
|
||||
name=name,
|
||||
apply=apply,
|
||||
subprotocols=subprotocols,
|
||||
websocket=True,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)
|
||||
|
||||
def add_websocket_route(
|
||||
self,
|
||||
handler,
|
||||
uri: str,
|
||||
host: str | list[str] | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
subprotocols=None,
|
||||
version: int | str | float | None = None,
|
||||
name: str | None = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: str | None = None,
|
||||
**ctx_kwargs: Any,
|
||||
):
|
||||
"""A helper method to register a function as a websocket route.
|
||||
|
||||
Args:
|
||||
handler (Callable): A callable function or instance of a class
|
||||
that can handle the websocket request.
|
||||
uri (str): URL path that will be mapped to the websocket handler.
|
||||
host (Optional[Union[str, List[str]]]): Host IP or FQDN details.
|
||||
strict_slashes (Optional[bool]): If the API endpoint needs to
|
||||
terminate with a `"/"` or not.
|
||||
subprotocols (Optional[List[str]]): Subprotocols to be used with
|
||||
websocket handshake.
|
||||
version (Optional[Union[int, str, float]]): Versioning information.
|
||||
name (Optional[str]): A unique name assigned to the URL.
|
||||
version_prefix (str): URL path before the version value.
|
||||
Defaults to `"/v"`.
|
||||
error_format (Optional[str]): Format for error handling.
|
||||
**ctx_kwargs (Any): Keyword arguments beginning with `ctx_*`
|
||||
prefix will be appended to the route context (`route.ctx`).
|
||||
|
||||
Returns:
|
||||
Callable: Object passed as the handler.
|
||||
"""
|
||||
return self.websocket(
|
||||
uri=uri,
|
||||
host=host,
|
||||
strict_slashes=strict_slashes,
|
||||
subprotocols=subprotocols,
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
**ctx_kwargs,
|
||||
)(handler)
|
||||
|
||||
def _determine_error_format(self, handler) -> str:
|
||||
with suppress(OSError, TypeError):
|
||||
src = dedent(getsource(handler))
|
||||
tree = parse(src)
|
||||
http_response_types = self._get_response_types(tree)
|
||||
|
||||
if len(http_response_types) == 1:
|
||||
return next(iter(http_response_types))
|
||||
|
||||
return ""
|
||||
|
||||
def _get_response_types(self, node):
|
||||
types = set()
|
||||
|
||||
class HttpResponseVisitor(NodeVisitor):
|
||||
def visit_Return(self, node: Return) -> Any:
|
||||
nonlocal types
|
||||
|
||||
with suppress(AttributeError):
|
||||
checks = [node.value.func.id] # type: ignore
|
||||
if node.value.keywords: # type: ignore
|
||||
checks += [
|
||||
k.value
|
||||
for k in node.value.keywords # type: ignore
|
||||
if k.arg == "content_type"
|
||||
]
|
||||
|
||||
for check in checks:
|
||||
if check in RESPONSE_MAPPING:
|
||||
types.add(RESPONSE_MAPPING[check])
|
||||
|
||||
HttpResponseVisitor().visit(node)
|
||||
|
||||
return types
|
||||
|
||||
def _build_route_context(self, raw: dict[str, Any]) -> HashableDict:
|
||||
ctx_kwargs = {
|
||||
key.replace("ctx_", ""): raw.pop(key)
|
||||
for key in {**raw}.keys()
|
||||
if key.startswith("ctx_")
|
||||
}
|
||||
if raw:
|
||||
unexpected_arguments = ", ".join(raw.keys())
|
||||
raise TypeError(
|
||||
f"Unexpected keyword arguments: {unexpected_arguments}"
|
||||
)
|
||||
return HashableDict(ctx_kwargs)
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Coroutine
|
||||
from enum import Enum
|
||||
from typing import Any, Callable
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.models.futures import FutureSignal
|
||||
from sanic.models.handler_types import SignalHandler
|
||||
from sanic.signals import Event, Signal
|
||||
from sanic.types import HashableDict
|
||||
|
||||
|
||||
class SignalMixin(metaclass=SanicMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_signals: set[FutureSignal] = set()
|
||||
|
||||
def _apply_signal(self, signal: FutureSignal) -> Signal:
|
||||
raise NotImplementedError # noqa
|
||||
|
||||
def signal(
|
||||
self,
|
||||
event: str | Enum,
|
||||
*,
|
||||
apply: bool = True,
|
||||
condition: dict[str, Any] | None = None,
|
||||
exclusive: bool = True,
|
||||
priority: int = 0,
|
||||
) -> Callable[[SignalHandler], SignalHandler]:
|
||||
"""
|
||||
For creating a signal handler, used similar to a route handler:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@app.signal("foo.bar.<thing>")
|
||||
async def signal_handler(thing, **kwargs):
|
||||
print(f"[signal_handler] {thing=}", kwargs)
|
||||
|
||||
:param event: Representation of the event in ``one.two.three`` form
|
||||
:type event: str
|
||||
:param apply: For lazy evaluation, defaults to ``True``
|
||||
:type apply: bool, optional
|
||||
:param condition: For use with the ``condition`` argument in dispatch
|
||||
filtering, defaults to ``None``
|
||||
:param exclusive: When ``True``, the signal can only be dispatched
|
||||
when the condition has been met. When ``False``, the signal can
|
||||
be dispatched either with or without it. *THIS IS INAPPLICABLE TO
|
||||
BLUEPRINT SIGNALS. THEY ARE ALWAYS NON-EXCLUSIVE*, defaults
|
||||
to ``True``
|
||||
:type condition: Dict[str, Any], optional
|
||||
"""
|
||||
event_value = str(event.value) if isinstance(event, Enum) else event
|
||||
|
||||
def decorator(handler: SignalHandler):
|
||||
future_signal = FutureSignal(
|
||||
handler,
|
||||
event_value,
|
||||
HashableDict(condition or {}),
|
||||
exclusive,
|
||||
priority,
|
||||
)
|
||||
self._future_signals.add(future_signal)
|
||||
|
||||
if apply:
|
||||
self._apply_signal(future_signal)
|
||||
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
def add_signal(
|
||||
self,
|
||||
handler: Callable[..., Any] | None,
|
||||
event: str | Enum,
|
||||
condition: dict[str, Any] | None = None,
|
||||
exclusive: bool = True,
|
||||
) -> Callable[..., Any]:
|
||||
"""Registers a signal handler for a specific event.
|
||||
|
||||
Args:
|
||||
handler (Optional[Callable[..., Any]]): The function to be called
|
||||
when the event occurs. Defaults to a noop if not provided.
|
||||
event (str): The name of the event to listen for.
|
||||
condition (Optional[Dict[str, Any]]): Optional condition to filter
|
||||
the event triggering. Defaults to `None`.
|
||||
exclusive (bool): Whether or not the handler is exclusive. When
|
||||
`True`, the signal can only be dispatched when the
|
||||
`condition` has been met. *This is inapplicable to blueprint
|
||||
signals, which are **ALWAYS** non-exclusive.* Defaults
|
||||
to `True`.
|
||||
|
||||
Returns:
|
||||
Callable[..., Any]: The handler that was registered.
|
||||
"""
|
||||
if not handler:
|
||||
|
||||
async def noop(**context): ...
|
||||
|
||||
handler = noop
|
||||
self.signal(event=event, condition=condition, exclusive=exclusive)(
|
||||
handler
|
||||
)
|
||||
return handler
|
||||
|
||||
def event(self, event: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def catch_exception(
|
||||
self,
|
||||
handler: Callable[[SignalMixin, Exception], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""Register an exception handler for logging or processing.
|
||||
|
||||
This method allows the registration of a custom exception handler to
|
||||
catch and process exceptions that occur in the application. Unlike a
|
||||
typical exception handler that might modify the response to the client,
|
||||
this is intended to capture exceptions for logging or other internal
|
||||
processing, such as sending them to an error reporting utility.
|
||||
|
||||
Args:
|
||||
handler (Callable): A coroutine function that takes the application
|
||||
instance and the exception as arguments. It will be called when
|
||||
an exception occurs within the application's lifecycle.
|
||||
|
||||
Example:
|
||||
```python
|
||||
app = Sanic("TestApp")
|
||||
|
||||
@app.catch_exception
|
||||
async def report_exception(app: Sanic, exception: Exception):
|
||||
logging.error(f"An exception occurred: {exception}")
|
||||
|
||||
# Send to an error reporting service
|
||||
await error_service.report(exception)
|
||||
|
||||
# Any unhandled exceptions within the application will now be
|
||||
# logged and reported to the error service.
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
async def signal_handler(exception: Exception):
|
||||
await handler(self, exception)
|
||||
|
||||
self.signal(Event.SERVER_EXCEPTION_REPORT)(signal_handler)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,425 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from email.utils import formatdate
|
||||
from functools import partial, wraps
|
||||
from os import PathLike, path
|
||||
from pathlib import Path, PurePath
|
||||
from urllib.parse import unquote
|
||||
|
||||
from sanic_routing.route import Route
|
||||
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.compat import clear_function_annotate, stat_async
|
||||
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
|
||||
from sanic.handlers import ContentRangeHandler
|
||||
from sanic.handlers.directory import DirectoryHandler
|
||||
from sanic.log import error_logger
|
||||
from sanic.mixins.base import BaseMixin
|
||||
from sanic.models.futures import FutureStatic
|
||||
from sanic.request import Request
|
||||
from sanic.response import HTTPResponse, file, file_stream, validate_file
|
||||
from sanic.response.convenience import guess_content_type
|
||||
|
||||
|
||||
class StaticMixin(BaseMixin, metaclass=SanicMeta):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._future_statics: set[FutureStatic] = set()
|
||||
|
||||
def _apply_static(self, static: FutureStatic) -> Route:
|
||||
raise NotImplementedError # noqa
|
||||
|
||||
def static(
|
||||
self,
|
||||
uri: str,
|
||||
file_or_directory: PathLike | str,
|
||||
pattern: str = r"/?.+",
|
||||
use_modified_since: bool = True,
|
||||
use_content_range: bool = False,
|
||||
stream_large_files: bool | int = False,
|
||||
name: str = "static",
|
||||
host: str | None = None,
|
||||
strict_slashes: bool | None = None,
|
||||
content_type: str | None = None,
|
||||
apply: bool = True,
|
||||
resource_type: str | None = None,
|
||||
index: str | Sequence[str] | None = None,
|
||||
directory_view: bool = False,
|
||||
directory_handler: DirectoryHandler | None = None,
|
||||
follow_external_symlink_files: bool = False,
|
||||
follow_external_symlink_dirs: bool = False,
|
||||
):
|
||||
"""Register a root to serve files from. The input can either be a file or a directory.
|
||||
|
||||
This method provides an easy and simple way to set up the route necessary to serve static files.
|
||||
|
||||
Args:
|
||||
uri (str): URL path to be used for serving static content.
|
||||
file_or_directory (Union[PathLike, str]): Path to the static file
|
||||
or directory with static files.
|
||||
pattern (str, optional): Regex pattern identifying the valid
|
||||
static files. Defaults to `r"/?.+"`.
|
||||
use_modified_since (bool, optional): If true, send file modified
|
||||
time, and return not modified if the browser's matches the
|
||||
server's. Defaults to `True`.
|
||||
use_content_range (bool, optional): If true, process header for
|
||||
range requests and sends the file part that is requested.
|
||||
Defaults to `False`.
|
||||
stream_large_files (Union[bool, int], optional): If `True`, use
|
||||
the `StreamingHTTPResponse.file_stream` handler rather than
|
||||
the `HTTPResponse.file handler` to send the file. If this
|
||||
is an integer, it represents the threshold size to switch
|
||||
to `StreamingHTTPResponse.file_stream`. Defaults to `False`,
|
||||
which means that the response will not be streamed.
|
||||
name (str, optional): User-defined name used for url_for.
|
||||
Defaults to `"static"`.
|
||||
host (Optional[str], optional): Host IP or FQDN for the
|
||||
service to use.
|
||||
strict_slashes (Optional[bool], optional): Instruct Sanic to
|
||||
check if the request URLs need to terminate with a slash.
|
||||
content_type (Optional[str], optional): User-defined content type
|
||||
for header.
|
||||
apply (bool, optional): If true, will register the route
|
||||
immediately. Defaults to `True`.
|
||||
resource_type (Optional[str], optional): Explicitly declare a
|
||||
resource to be a `"file"` or a `"dir"`.
|
||||
index (Optional[Union[str, Sequence[str]]], optional): When
|
||||
exposing against a directory, index is the name that will
|
||||
be served as the default file. When multiple file names are
|
||||
passed, then they will be tried in order.
|
||||
directory_view (bool, optional): Whether to fallback to showing
|
||||
the directory viewer when exposing a directory. Defaults
|
||||
to `False`.
|
||||
directory_handler (Optional[DirectoryHandler], optional): An
|
||||
instance of DirectoryHandler that can be used for explicitly
|
||||
controlling and subclassing the behavior of the default
|
||||
directory handler.
|
||||
follow_external_symlink_files (bool, optional): Whether to serve
|
||||
files that are symlinks pointing outside the static root.
|
||||
Defaults to `False` for security.
|
||||
follow_external_symlink_dirs (bool, optional): Whether to serve
|
||||
files from directories that are symlinks pointing outside
|
||||
the static root. Defaults to `False` for security.
|
||||
|
||||
Returns:
|
||||
List[sanic.router.Route]: Routes registered on the router.
|
||||
|
||||
Examples:
|
||||
Serving a single file:
|
||||
```python
|
||||
app.static('/foo', 'path/to/static/file.txt')
|
||||
```
|
||||
|
||||
Serving all files from a directory:
|
||||
```python
|
||||
app.static('/static', 'path/to/static/directory')
|
||||
```
|
||||
|
||||
Serving large files with a specific threshold:
|
||||
```python
|
||||
app.static('/static', 'path/to/large/files', stream_large_files=1000000)
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
name = self.generate_name(name)
|
||||
|
||||
if strict_slashes is None and self.strict_slashes is not None:
|
||||
strict_slashes = self.strict_slashes
|
||||
|
||||
if not isinstance(file_or_directory, (str, bytes, PurePath)):
|
||||
raise ValueError(
|
||||
f"Static route must be a valid path, not {file_or_directory}"
|
||||
)
|
||||
|
||||
try:
|
||||
file_or_directory = Path(file_or_directory).resolve()
|
||||
except TypeError:
|
||||
raise TypeError(
|
||||
"Static file or directory must be a path-like object or string"
|
||||
)
|
||||
|
||||
if directory_handler and (directory_view or index):
|
||||
raise ValueError(
|
||||
"When explicitly setting directory_handler, you cannot "
|
||||
"set either directory_view or index. Instead, pass "
|
||||
"these arguments to your DirectoryHandler instance."
|
||||
)
|
||||
|
||||
if not directory_handler:
|
||||
directory_handler = DirectoryHandler(
|
||||
uri=uri,
|
||||
directory=file_or_directory,
|
||||
directory_view=directory_view,
|
||||
index=index,
|
||||
root_path=file_or_directory,
|
||||
follow_external_symlink_files=follow_external_symlink_files,
|
||||
follow_external_symlink_dirs=follow_external_symlink_dirs,
|
||||
)
|
||||
|
||||
static = FutureStatic(
|
||||
uri,
|
||||
file_or_directory,
|
||||
pattern,
|
||||
use_modified_since,
|
||||
use_content_range,
|
||||
stream_large_files,
|
||||
name,
|
||||
host,
|
||||
strict_slashes,
|
||||
content_type,
|
||||
resource_type,
|
||||
directory_handler,
|
||||
follow_external_symlink_files,
|
||||
follow_external_symlink_dirs,
|
||||
)
|
||||
self._future_statics.add(static)
|
||||
|
||||
if apply:
|
||||
self._apply_static(static)
|
||||
|
||||
|
||||
class StaticHandleMixin(metaclass=SanicMeta):
|
||||
def _apply_static(self, static: FutureStatic) -> Route:
|
||||
return self._register_static(static)
|
||||
|
||||
def _register_static(
|
||||
self,
|
||||
static: FutureStatic,
|
||||
):
|
||||
# TODO: Though sanic is not a file server, I feel like we should
|
||||
# at least make a good effort here. Modified-since is nice, but
|
||||
# we could also look into etags, expires, and caching
|
||||
"""
|
||||
Register a static directory handler with Sanic by adding a route to the
|
||||
router and registering a handler.
|
||||
"""
|
||||
file_or_directory: PathLike
|
||||
|
||||
if isinstance(static.file_or_directory, bytes):
|
||||
file_or_directory = Path(static.file_or_directory.decode("utf-8"))
|
||||
elif isinstance(static.file_or_directory, PurePath):
|
||||
file_or_directory = static.file_or_directory
|
||||
elif isinstance(static.file_or_directory, str):
|
||||
file_or_directory = Path(static.file_or_directory)
|
||||
else:
|
||||
raise ValueError("Invalid file path string.")
|
||||
|
||||
uri = static.uri
|
||||
name = static.name
|
||||
# If we're not trying to match a file directly,
|
||||
# serve from the folder
|
||||
if not static.resource_type:
|
||||
if not path.isfile(file_or_directory):
|
||||
uri = uri.rstrip("/")
|
||||
uri += "/<__file_uri__:path>"
|
||||
elif static.resource_type == "dir":
|
||||
if path.isfile(file_or_directory):
|
||||
raise TypeError(
|
||||
"Resource type improperly identified as directory. "
|
||||
f"'{file_or_directory}'"
|
||||
)
|
||||
uri = uri.rstrip("/")
|
||||
uri += "/<__file_uri__:path>"
|
||||
elif static.resource_type == "file" and not path.isfile(
|
||||
file_or_directory
|
||||
):
|
||||
raise TypeError(
|
||||
"Resource type improperly identified as file. "
|
||||
f"'{file_or_directory}'"
|
||||
)
|
||||
elif static.resource_type != "file":
|
||||
raise ValueError(
|
||||
"The resource_type should be set to 'file' or 'dir'"
|
||||
)
|
||||
|
||||
# special prefix for static files
|
||||
# if not static.name.startswith("_static_"):
|
||||
# name = f"_static_{static.name}"
|
||||
|
||||
_handler = wraps(self._static_request_handler)(
|
||||
partial(
|
||||
self._static_request_handler,
|
||||
file_or_directory=str(file_or_directory),
|
||||
use_modified_since=static.use_modified_since,
|
||||
use_content_range=static.use_content_range,
|
||||
stream_large_files=static.stream_large_files,
|
||||
content_type=static.content_type,
|
||||
directory_handler=static.directory_handler,
|
||||
follow_external_symlink_files=static.follow_external_symlink_files,
|
||||
follow_external_symlink_dirs=static.follow_external_symlink_dirs,
|
||||
)
|
||||
)
|
||||
|
||||
route, _ = self.route( # type: ignore
|
||||
uri=uri,
|
||||
methods=["GET", "HEAD"],
|
||||
name=name,
|
||||
host=static.host,
|
||||
strict_slashes=static.strict_slashes,
|
||||
static=True,
|
||||
)(_handler)
|
||||
|
||||
return route
|
||||
|
||||
async def _static_request_handler(
|
||||
self,
|
||||
request: Request,
|
||||
*,
|
||||
file_or_directory: str,
|
||||
use_modified_since: bool,
|
||||
use_content_range: bool,
|
||||
stream_large_files: bool | int,
|
||||
directory_handler: DirectoryHandler,
|
||||
follow_external_symlink_files: bool,
|
||||
follow_external_symlink_dirs: bool,
|
||||
content_type: str | None = None,
|
||||
__file_uri__: str | None = None,
|
||||
):
|
||||
not_found = FileNotFound(
|
||||
"File not found",
|
||||
path=Path(file_or_directory),
|
||||
relative_url=__file_uri__,
|
||||
)
|
||||
|
||||
# Merge served directory and requested file if provided
|
||||
file_path = await self._get_file_path(
|
||||
file_or_directory,
|
||||
__file_uri__,
|
||||
not_found,
|
||||
follow_external_symlink_files,
|
||||
follow_external_symlink_dirs,
|
||||
)
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
# Check if the client has been sent this file before
|
||||
# and it has not been modified since
|
||||
stats = None
|
||||
if use_modified_since:
|
||||
stats = await stat_async(file_path)
|
||||
modified_since = stats.st_mtime
|
||||
response = await validate_file(request.headers, modified_since)
|
||||
if response:
|
||||
return response
|
||||
headers["Last-Modified"] = formatdate(
|
||||
modified_since, usegmt=True
|
||||
)
|
||||
_range = None
|
||||
if use_content_range:
|
||||
_range = None
|
||||
if not stats:
|
||||
stats = await stat_async(file_path)
|
||||
headers["Accept-Ranges"] = "bytes"
|
||||
headers["Content-Length"] = str(stats.st_size)
|
||||
if request.method != "HEAD":
|
||||
try:
|
||||
_range = ContentRangeHandler(request, stats)
|
||||
except HeaderNotFound:
|
||||
pass
|
||||
else:
|
||||
del headers["Content-Length"]
|
||||
headers.update(_range.headers)
|
||||
|
||||
if "content-type" not in headers:
|
||||
content_type = content_type or guess_content_type(file_path)
|
||||
|
||||
if "charset=" not in content_type and (
|
||||
content_type.startswith("text/")
|
||||
or content_type == "application/javascript"
|
||||
):
|
||||
content_type += "; charset=utf-8"
|
||||
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
if request.method == "HEAD":
|
||||
return HTTPResponse(headers=headers)
|
||||
else:
|
||||
if stream_large_files:
|
||||
if isinstance(stream_large_files, bool):
|
||||
threshold = 1024 * 1024
|
||||
else:
|
||||
threshold = stream_large_files
|
||||
|
||||
if not stats:
|
||||
stats = await stat_async(file_path)
|
||||
if stats.st_size >= threshold:
|
||||
return await file_stream(
|
||||
file_path, headers=headers, _range=_range
|
||||
)
|
||||
return await file(file_path, headers=headers, _range=_range)
|
||||
except (IsADirectoryError, PermissionError):
|
||||
return await directory_handler.handle(request, request.path)
|
||||
except RangeNotSatisfiable:
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
raise not_found
|
||||
except Exception:
|
||||
error_logger.exception(
|
||||
"Exception in static request handler: "
|
||||
f"path={file_or_directory}, "
|
||||
f"relative_url={__file_uri__}"
|
||||
)
|
||||
raise
|
||||
|
||||
async def _get_file_path(
|
||||
self,
|
||||
file_or_directory,
|
||||
__file_uri__,
|
||||
not_found,
|
||||
follow_external_symlink_files: bool,
|
||||
follow_external_symlink_dirs: bool,
|
||||
):
|
||||
"""
|
||||
Resolve a filesystem path safely.
|
||||
|
||||
Security goals:
|
||||
- Prevent path traversal via `..`
|
||||
- Prevent escaping the root via symlinks unless explicitly allowed
|
||||
- Treat file URIs as relative paths even if they look absolute
|
||||
"""
|
||||
|
||||
def reject():
|
||||
error_logger.exception(
|
||||
f"File not found: path={file_or_directory}, "
|
||||
f"relative_url={__file_uri__}"
|
||||
)
|
||||
raise not_found
|
||||
|
||||
root_raw = Path(unquote(file_or_directory))
|
||||
root_path = root_raw.resolve()
|
||||
file_path_raw = root_raw
|
||||
|
||||
if __file_uri__:
|
||||
# URLs may start with `/`, Path() interprets as absolute
|
||||
rel_uri = unquote(__file_uri__).lstrip("/")
|
||||
file_path_raw = Path(root_raw, rel_uri)
|
||||
|
||||
if ".." in file_path_raw.parts:
|
||||
reject()
|
||||
|
||||
file_path = file_path_raw.resolve()
|
||||
|
||||
try:
|
||||
file_path.relative_to(root_path)
|
||||
except ValueError:
|
||||
# Check if it's a symlink and determine its type
|
||||
is_file_symlink = (
|
||||
file_path_raw.is_symlink() and not file_path.is_dir()
|
||||
)
|
||||
if is_file_symlink:
|
||||
allowed = follow_external_symlink_files
|
||||
else:
|
||||
allowed = follow_external_symlink_dirs
|
||||
if not allowed:
|
||||
reject()
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
# Clear __annotate__ on methods that may be pickled via functools.partial
|
||||
# to avoid PicklingError in Python 3.14+ (PEP 649)
|
||||
clear_function_annotate(
|
||||
StaticHandleMixin._static_request_handler,
|
||||
StaticHandleMixin._get_file_path,
|
||||
StaticHandleMixin._register_static,
|
||||
)
|
||||
Reference in New Issue
Block a user