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:
leetcrypt
2026-05-30 13:29:14 -07:00
parent 82a04f3e12
commit bb1d662ee1
2730 changed files with 712933 additions and 46 deletions
@@ -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,
)