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,86 @@
from types import SimpleNamespace
from typing_extensions import TypeAlias
from sanic.__version__ import __version__
from sanic.app import Sanic
from sanic.blueprints import Blueprint
from sanic.config import Config
from sanic.constants import HTTPMethod
from sanic.exceptions import (
BadRequest,
ExpectationFailed,
FileNotFound,
Forbidden,
HeaderNotFound,
InternalServerError,
InvalidHeader,
MethodNotAllowed,
NotFound,
RangeNotSatisfiable,
SanicException,
ServerError,
ServiceUnavailable,
Unauthorized,
)
from sanic.request import Request
from sanic.response import (
HTTPResponse,
empty,
file,
html,
json,
raw,
redirect,
text,
)
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
DefaultSanic: TypeAlias = "Sanic[Config, SimpleNamespace]"
"""
A type alias for a Sanic app with a default config and namespace.
"""
DefaultRequest: TypeAlias = Request[DefaultSanic, SimpleNamespace]
"""
A type alias for a request with a default Sanic app and namespace.
"""
__all__ = (
"__version__",
# Common objects
"Sanic",
"Config",
"Blueprint",
"HTTPMethod",
"HTTPResponse",
"Request",
"Websocket",
# Common types
"DefaultSanic",
"DefaultRequest",
# Common exceptions
"BadRequest",
"ExpectationFailed",
"FileNotFound",
"Forbidden",
"HeaderNotFound",
"InternalServerError",
"InvalidHeader",
"MethodNotAllowed",
"NotFound",
"RangeNotSatisfiable",
"SanicException",
"ServerError",
"ServiceUnavailable",
"Unauthorized",
# Common response methods
"empty",
"file",
"html",
"json",
"raw",
"redirect",
"text",
)
@@ -0,0 +1,29 @@
import tracerite
try:
tracerite.load()
except Exception as exc:
raise RuntimeError(
"Failed to initialize tracerite. Please verify that tracerite is "
"correctly installed and compatible with this environment."
) from exc
from sanic.cli.app import SanicCLI
from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support
from sanic.startup.errors import maybe_handle_startup_error
if OS_IS_WINDOWS:
enable_windows_color_support()
def main(args=None):
try:
cli = SanicCLI()
cli.attach()
cli.run(args)
except Exception as e:
maybe_handle_startup_error(e)
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
__version__ = "25.12.0"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
from enum import Enum, IntEnum, auto
class StrEnum(str, Enum): # no cov
def _generate_next_value_(name: str, *args) -> str: # type: ignore
return name.lower()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
class Server(StrEnum):
"""Server types."""
SANIC = auto()
ASGI = auto()
class Mode(StrEnum):
"""Server modes."""
PRODUCTION = auto()
DEBUG = auto()
class ServerStage(IntEnum):
"""Server stages."""
STOPPED = auto()
PARTIAL = auto()
SERVING = auto()
@@ -0,0 +1,48 @@
from __future__ import annotations
from contextlib import suppress
from importlib import import_module
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sanic import Sanic
def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
"""Setup Sanic Extensions.
Requires Sanic Extensions to be installed.
Args:
app (Sanic): Sanic application.
fail (bool, optional): Raise an error if Sanic Extensions is not
installed. Defaults to `False`.
**kwargs: Keyword arguments to pass to `sanic_ext.Extend`.
Returns:
sanic_ext.Extend: Sanic Extensions instance.
"""
if not app.config.AUTO_EXTEND:
return
sanic_ext = None
with suppress(ModuleNotFoundError):
sanic_ext = import_module("sanic_ext")
if not sanic_ext: # no cov
if fail:
raise RuntimeError(
"Sanic Extensions is not installed. You can add it to your "
"environment using:\n$ pip install sanic[ext]\nor\n$ pip "
"install sanic-ext"
)
return
if not getattr(app, "_ext", None):
Ext = getattr(sanic_ext, "Extend")
app._ext = Ext(app, **kwargs)
return app.ext
@@ -0,0 +1,72 @@
import re
import sys
from os import environ
from sanic.helpers import is_atty
BASE_LOGO = """
Sanic
Build Fast. Run Fast.
"""
COFFEE_LOGO = """\033[48;2;255;13;104m \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ▄████████▄ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ██ ██▀▀▄ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ███████████ █ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ███████████▄▄▀ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ▀███████▀ \033[0m
\033[48;2;255;13;104m \033[0m
Dark roast. No sugar."""
COLOR_LOGO = """\033[48;2;255;13;104m \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ▄███ █████ ██ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ██ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ▀███████ ███▄ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ██ \033[0m
\033[38;2;255;255;255;48;2;255;13;104m ████ ████████▀ \033[0m
\033[48;2;255;13;104m \033[0m
Build Fast. Run Fast."""
FULL_COLOR_LOGO = """
\033[38;2;255;13;104m ▄███ █████ ██ \033[0m ▄█▄ ██ █ █ ▄██████████
\033[38;2;255;13;104m ██ \033[0m █ █ █ ██ █ █ ██
\033[38;2;255;13;104m ▀███████ ███▄ \033[0m ▀ █ █ ██ ▄ █ ██
\033[38;2;255;13;104m ██\033[0m █████████ █ ██ █ █ ▄▄
\033[38;2;255;13;104m ████ ████████▀ \033[0m █ █ █ ██ █ ▀██ ███████
""" # noqa
SVG_LOGO_SIMPLE = """<svg id=logo-simple viewBox="0 0 964 279"><desc>Sanic</desc><path d="M107 222c9-2 10-20 1-22s-20-2-30-2-17 7-16 14 6 10 15 10h30zm115-1c16-2 30-11 35-23s6-24 2-33-6-14-15-20-24-11-38-10c-7 3-10 13-5 19s17-1 24 4 15 14 13 24-5 15-14 18-50 0-74 0h-17c-6 4-10 15-4 20s16 2 23 3zM251 83q9-1 9-7 0-15-10-16h-13c-10 6-10 20 0 22zM147 60c-4 0-10 3-11 11s5 13 10 12 42 0 67 0c5-3 7-10 6-15s-4-8-9-8zm-33 1c-8 0-16 0-24 3s-20 10-25 20-6 24-4 36 15 22 26 27 78 8 94 3c4-4 4-12 0-18s-69 8-93-10c-8-7-9-23 0-30s12-10 20-10 12 2 16-3 1-15-5-18z" fill="#ff0d68"/><path d="M676 74c0-14-18-9-20 0s0 30 0 39 20 9 20 2zm-297-10c-12 2-15 12-23 23l-41 58H340l22-30c8-12 23-13 30-4s20 24 24 38-10 10-17 10l-68 2q-17 1-48 30c-7 6-10 20 0 24s15-8 20-13 20 -20 58-21h50 c20 2 33 9 52 30 8 10 24-4 16-13L384 65q-3-2-5-1zm131 0c-10 1-12 12-11 20v96c1 10-3 23 5 32s20-5 17-15c0-23-3-46 2-67 5-12 22-14 32-5l103 87c7 5 19 1 18-9v-64c-3-10-20-9-21 2s-20 22-30 13l-97-80c-5-4-10-10-18-10zM701 76v128c2 10 15 12 20 4s0-102 0-124s-20-18-20-7z M850 63c-35 0-69-2-86 15s-20 60-13 66 13 8 16 0 1-10 1-27 12-26 20-32 66-5 85-5 31 4 31-10-18-7-54-7M764 159c-6-2-15-2-16 12s19 37 33 43 23 8 25-4-4-11-11-14q-9-3-22-18c-4-7-3-16-10-19zM828 196c-4 0-8 1-10 5s-4 12 0 15 8 2 12 2h60c5 0 10-2 12-6 3-7-1-16-8-16z" fill="#1f1f1f"/></svg>""" # noqa
ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def get_logo(full: bool = False, coffee: bool = False) -> str:
"""Get the Sanic logo.
Will return the full color logo if the terminal supports it.
Args:
full (bool, optional): Use the full color logo. Defaults to `False`.
coffee (bool, optional): Use the coffee logo. Defaults to `False`.
Returns:
str: Sanic logo.
"""
logo = (
(FULL_COLOR_LOGO if full else (COFFEE_LOGO if coffee else COLOR_LOGO))
if is_atty()
else BASE_LOGO
)
if (
sys.platform == "darwin"
and environ.get("TERM_PROGRAM") == "Apple_Terminal"
):
logo = ansi_pattern.sub("", logo)
return logo
@@ -0,0 +1,179 @@
from abc import ABC, abstractmethod
from shutil import get_terminal_size
from textwrap import indent, wrap
from sanic import __version__
from sanic.helpers import is_atty
from sanic.log import logger
class MOTD(ABC):
"""Base class for the Message of the Day (MOTD) display."""
def __init__(
self,
logo: str | None,
serve_location: str,
data: dict[str, str],
extra: dict[str, str],
) -> None:
self.logo = logo
self.serve_location = serve_location
self.data = data
self.extra = extra
self.key_width = 0
self.value_width = 0
@abstractmethod
def display(self):
"""Display the MOTD."""
@classmethod
def output(
cls,
logo: str | None,
serve_location: str,
data: dict[str, str],
extra: dict[str, str],
) -> None:
"""Output the MOTD.
Args:
logo (Optional[str]): Logo to display.
serve_location (str): Location to serve.
data (Dict[str, str]): Data to display.
extra (Dict[str, str]): Extra data to display.
"""
motd_class = MOTDTTY if is_atty() else MOTDBasic
motd_class(logo, serve_location, data, extra).display()
class MOTDBasic(MOTD):
"""A basic MOTD display.
This is used when the terminal does not support ANSI escape codes.
"""
def display(self):
if self.logo:
logger.debug(self.logo)
lines = [f"Sanic v{__version__}"]
if self.serve_location:
lines.append(f"Goin' Fast @ {self.serve_location}")
lines += [
*(f"{key}: {value}" for key, value in self.data.items()),
*(f"{key}: {value}" for key, value in self.extra.items()),
]
for line in lines:
logger.info(line)
class MOTDTTY(MOTD):
"""A MOTD display for terminals that support ANSI escape codes."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.set_variables()
def set_variables(self): # no cov
"""Set the variables used for display."""
fallback = (108, 24)
terminal_width = max(
get_terminal_size(fallback=fallback).columns, fallback[0]
)
self.max_value_width = terminal_width - fallback[0] + 36
self.key_width = 4
self.value_width = self.max_value_width
if self.data:
self.key_width = max(map(len, self.data.keys()))
self.value_width = min(
max(map(len, self.data.values())), self.max_value_width
)
if self.extra:
self.key_width = max(
self.key_width, max(map(len, self.extra.keys()))
)
self.value_width = min(
max((*map(len, self.extra.values()), self.value_width)),
self.max_value_width,
)
self.logo_lines = self.logo.split("\n") if self.logo else []
self.logo_line_length = 24
self.centering_length = (
self.key_width + self.value_width + 2 + self.logo_line_length
)
self.display_length = self.key_width + self.value_width + 2
def display(self, version=True, action="Goin' Fast", out=None):
"""Display the MOTD.
Args:
version (bool, optional): Display the version. Defaults to `True`.
action (str, optional): Action to display. Defaults to
`"Goin' Fast"`.
out (Optional[Callable], optional): Output function. Defaults to
`None`.
"""
if not out:
out = logger.info
header = "Sanic"
if version:
header += f" v{__version__}"
header = header.center(self.centering_length)
running = (
f"{action} @ {self.serve_location}" if self.serve_location else ""
).center(self.centering_length)
length = len(header) + 2 - self.logo_line_length
first_filler = "" * (self.logo_line_length - 1)
second_filler = "" * length
display_filler = "" * (self.display_length + 2)
lines = [
f"\n{first_filler}{second_filler}",
f"{header}",
f"{running}",
f"{first_filler}{second_filler}",
]
self._render_data(lines, self.data, 0)
if self.extra:
logo_part = self._get_logo_part(len(lines) - 4)
lines.append(f"{logo_part}{display_filler}")
self._render_data(lines, self.extra, len(lines) - 4)
self._render_fill(lines)
lines.append(f"{first_filler}{second_filler}\n")
out(indent("\n".join(lines), " "))
def _render_data(self, lines, data, start):
offset = 0
for idx, (key, value) in enumerate(data.items(), start=start):
key = key.rjust(self.key_width)
wrapped = wrap(value, self.max_value_width, break_on_hyphens=False)
for wrap_index, part in enumerate(wrapped):
part = part.ljust(self.value_width)
logo_part = self._get_logo_part(idx + offset + wrap_index)
display = (
f"{key}: {part}"
if wrap_index == 0
else (" " * len(key) + f" {part}")
)
lines.append(f"{logo_part}{display}")
if wrap_index:
offset += 1
def _render_fill(self, lines):
filler = " " * self.display_length
idx = len(lines) - 5
for i in range(1, len(self.logo_lines) - idx):
logo_part = self.logo_lines[idx + i]
lines.append(f"{logo_part}{filler}")
def _get_logo_part(self, idx):
try:
logo_part = self.logo_lines[idx]
except IndexError:
logo_part = " " * (self.logo_line_length - 3)
return logo_part
@@ -0,0 +1,90 @@
import os
import sys
import time
from contextlib import contextmanager
from queue import Queue
from threading import Thread
if os.name == "nt": # noqa
import ctypes # noqa
class _CursorInfo(ctypes.Structure):
_fields_ = [("size", ctypes.c_int), ("visible", ctypes.c_byte)]
class Spinner: # noqa
"""Spinner class to show a loading spinner in the terminal.
Used internally by the `loading` context manager.
"""
def __init__(self, message: str) -> None:
self.message = message
self.queue: Queue[int] = Queue()
self.spinner = self.cursor()
self.thread = Thread(target=self.run)
def start(self):
self.queue.put(1)
self.thread.start()
self.hide()
def run(self):
while self.queue.get():
output = f"\r{self.message} [{next(self.spinner)}]"
sys.stdout.write(output)
sys.stdout.flush()
time.sleep(0.1)
self.queue.put(1)
def stop(self):
self.queue.put(0)
self.thread.join()
self.show()
@staticmethod
def cursor():
while True:
yield from "|/-\\"
@staticmethod
def hide():
if os.name == "nt":
ci = _CursorInfo()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
ctypes.windll.kernel32.GetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
ci.visible = False
ctypes.windll.kernel32.SetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
elif os.name == "posix":
sys.stdout.write("\033[?25l")
sys.stdout.flush()
@staticmethod
def show():
if os.name == "nt":
ci = _CursorInfo()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
ctypes.windll.kernel32.GetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
ci.visible = True
ctypes.windll.kernel32.SetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
elif os.name == "posix":
sys.stdout.write("\033[?25h")
sys.stdout.flush()
@contextmanager
def loading(message: str = "Loading"): # noqa
spinner = Spinner(message)
spinner.start()
yield
spinner.stop()
@@ -0,0 +1,115 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from socket import socket
from ssl import SSLContext
from typing import TYPE_CHECKING, Any
from sanic.application.constants import Mode, Server, ServerStage
from sanic.log import VerbosityFilter, logger
from sanic.server.async_server import AsyncioServer
if TYPE_CHECKING:
from sanic import Sanic
@dataclass
class ApplicationServerInfo:
"""Information about a server instance."""
settings: dict[str, Any]
stage: ServerStage = field(default=ServerStage.STOPPED)
server: AsyncioServer | None = field(default=None)
@dataclass
class ApplicationState:
"""Application state.
This class is used to store the state of the application. It is
instantiated by the application and is available as `app.state`.
"""
app: Sanic
asgi: bool = field(default=False)
coffee: bool = field(default=False)
fast: bool = field(default=False)
host: str = field(default="")
port: int = field(default=0)
ssl: SSLContext | None = field(default=None)
sock: socket | None = field(default=None)
unix: str | None = field(default=None)
mode: Mode = field(default=Mode.PRODUCTION)
reload_dirs: set[Path] = field(default_factory=set)
auto_reload: bool = field(default=False)
server: Server = field(default=Server.SANIC)
is_running: bool = field(default=False)
is_started: bool = field(default=False)
is_stopping: bool = field(default=False)
verbosity: int = field(default=0)
workers: int = field(default=0)
primary: bool = field(default=True)
server_info: list[ApplicationServerInfo] = field(default_factory=list)
# This property relates to the ApplicationState instance and should
# not be changed except in the __post_init__ method
_init: bool = field(default=False)
def __post_init__(self) -> None:
self._init = True
def __setattr__(self, name: str, value: Any) -> None:
if self._init and name == "_init":
raise RuntimeError(
"Cannot change the value of _init after instantiation"
)
super().__setattr__(name, value)
if self._init and hasattr(self, f"set_{name}"):
getattr(self, f"set_{name}")(value)
def set_mode(self, value: str | Mode):
if hasattr(self.app, "error_handler"):
self.app.error_handler.debug = self.app.debug
if getattr(self.app, "configure_logging", False) and self.app.debug:
logger.setLevel(logging.DEBUG)
def set_verbosity(self, value: int) -> None:
"""Set the verbosity level.
Args:
value (int): Verbosity level.
"""
VerbosityFilter.verbosity = value
@property
def is_debug(self) -> bool:
"""Check if the application is in debug mode.
Returns:
bool: `True` if the application is in debug mode, `False`
otherwise.
"""
return self.mode is Mode.DEBUG
@property
def stage(self) -> ServerStage:
"""Get the server stage.
Returns:
ServerStage: Server stage.
"""
if not self.server_info:
return ServerStage.STOPPED
if all(info.stage is ServerStage.SERVING for info in self.server_info):
return ServerStage.SERVING
elif any(
info.stage is ServerStage.SERVING for info in self.server_info
):
return ServerStage.PARTIAL
return ServerStage.STOPPED
@@ -0,0 +1,263 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
from sanic.compat import Header
from sanic.exceptions import BadRequest, ServerError
from sanic.helpers import Default
from sanic.http import Stage
from sanic.log import error_logger, logger
from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
from sanic.request import Request
from sanic.response import BaseHTTPResponse
from sanic.server import ConnInfo
from sanic.server.websockets.connection import WebSocketConnection
if TYPE_CHECKING:
from sanic import Sanic
class Lifespan:
def __init__(
self, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
) -> None:
self.sanic_app = sanic_app
self.scope = scope
self.receive = receive
self.send = send
if "server.init.before" in self.sanic_app.signal_router.name_index:
logger.debug(
'You have set a listener for "before_server_start" '
"in ASGI mode. "
"It will be executed as early as possible, but not before "
"the ASGI server is started.",
extra={"verbosity": 1},
)
if "server.shutdown.after" in self.sanic_app.signal_router.name_index:
logger.debug(
'You have set a listener for "after_server_stop" '
"in ASGI mode. "
"It will be executed as late as possible, but not after "
"the ASGI server is stopped.",
extra={"verbosity": 1},
)
async def startup(self) -> None:
"""
Gather the listeners to fire on server start.
Because we are using a third-party server and not Sanic server, we do
not have access to fire anything BEFORE the server starts.
Therefore, we fire before_server_start and after_server_start
in sequence since the ASGI lifespan protocol only supports a single
startup event.
"""
await self.sanic_app._startup()
await self.sanic_app._server_event("init", "before")
await self.sanic_app._server_event("init", "after")
if not isinstance(self.sanic_app.config.USE_UVLOOP, Default):
warnings.warn(
"You have set the USE_UVLOOP configuration option, but Sanic "
"cannot control the event loop when running in ASGI mode."
"This option will be ignored."
)
async def shutdown(self) -> None:
"""
Gather the listeners to fire on server stop.
Because we are using a third-party server and not Sanic server, we do
not have access to fire anything AFTER the server stops.
Therefore, we fire before_server_stop and after_server_stop
in sequence since the ASGI lifespan protocol only supports a single
shutdown event.
"""
await self.sanic_app._server_event("shutdown", "before")
await self.sanic_app._server_event("shutdown", "after")
async def __call__(self) -> None:
while True:
message = await self.receive()
if message["type"] == "lifespan.startup":
try:
await self.startup()
except Exception as e:
error_logger.exception(e)
await self.send(
{"type": "lifespan.startup.failed", "message": str(e)}
)
else:
await self.send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
try:
await self.shutdown()
except Exception as e:
error_logger.exception(e)
await self.send(
{"type": "lifespan.shutdown.failed", "message": str(e)}
)
else:
await self.send({"type": "lifespan.shutdown.complete"})
return
class ASGIApp:
sanic_app: Sanic
request: Request
transport: MockTransport
lifespan: Lifespan
ws: WebSocketConnection | None
stage: Stage
response: BaseHTTPResponse | None
@classmethod
async def create(
cls,
sanic_app: Sanic,
scope: ASGIScope,
receive: ASGIReceive,
send: ASGISend,
) -> ASGIApp:
instance = cls()
instance.ws = None
instance.sanic_app = sanic_app
instance.transport = MockTransport(scope, receive, send)
instance.transport.loop = sanic_app.loop
instance.stage = Stage.IDLE
instance.response = None
instance.sanic_app.state.is_started = True
setattr(instance.transport, "add_task", sanic_app.loop.create_task)
try:
headers = Header(
[
(
key.decode("ASCII"),
value.decode(errors="surrogateescape"),
)
for key, value in scope.get("headers", [])
]
)
except UnicodeDecodeError:
raise BadRequest(
"Header names can only contain US-ASCII characters"
)
if scope["type"] == "http":
version = scope["http_version"]
method = scope["method"]
elif scope["type"] == "websocket":
version = "1.1"
method = "GET"
instance.ws = instance.transport.create_websocket_connection(
send, receive
)
else:
raise ServerError("Received unknown ASGI scope")
url_bytes, query = scope["raw_path"], scope["query_string"]
if query:
# httpx ASGI client sends query string as part of raw_path
url_bytes = url_bytes.split(b"?", 1)[0]
# All servers send them separately
url_bytes = b"%b?%b" % (url_bytes, query)
request_class = sanic_app.request_class or Request # type: ignore
instance.request = request_class(
url_bytes,
headers,
version,
method,
instance.transport,
sanic_app,
)
request_class._current.set(instance.request)
instance.request.stream = instance # type: ignore
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)
await instance.sanic_app.dispatch(
"http.lifecycle.request",
inline=True,
context={"request": instance.request},
fail_not_found=False,
)
return instance
async def read(self) -> bytes | None:
"""
Read and stream the body in chunks from an incoming ASGI message.
"""
if self.stage is Stage.IDLE:
self.stage = Stage.REQUEST
message = await self.transport.receive()
body = message.get("body", b"")
if not message.get("more_body", False):
self.request_body = False
if not body:
return None
return body
async def __aiter__(self):
while self.request_body:
data = await self.read()
if data:
yield data
def respond(self, response: BaseHTTPResponse):
if self.stage is not Stage.HANDLER:
self.stage = Stage.FAILED
raise RuntimeError("Response already started")
if self.response is not None:
self.response.stream = None
response.stream, self.response = self, response
return response
async def send(self, data, end_stream):
if self.stage is Stage.IDLE:
if not end_stream or data:
raise RuntimeError(
"There is no request to respond to, either the "
"response has already been sent or the "
"request has not been received yet."
)
return
if self.response and self.stage is Stage.HANDLER:
await self.transport.send(
{
"type": "http.response.start",
"status": self.response.status,
"headers": self.response.processed_headers,
}
)
response_body = getattr(self.response, "body", None)
if response_body:
data = response_body + data if data else response_body
self.stage = Stage.IDLE if end_stream else Stage.RESPONSE
await self.transport.send(
{
"type": "http.response.body",
"body": data.encode() if hasattr(data, "encode") else data,
"more_body": not end_stream,
}
)
_asgi_single_callable = True # We conform to ASGI 3.0 single-callable
async def __call__(self) -> None:
"""
Handle the incoming request.
"""
try:
self.stage = Stage.HANDLER
await self.sanic_app.handle_request(self.request)
except Exception as e:
try:
await self.sanic_app.handle_exception(self.request, e)
except Exception as exc:
await self.sanic_app.handle_exception(self.request, exc, False)
@@ -0,0 +1,6 @@
class SanicMeta(type):
@classmethod
def __prepare__(metaclass, name, bases, **kwds):
cls = super().__prepare__(metaclass, name, bases, **kwds)
cls["__slots__"] = ()
return cls
@@ -0,0 +1,69 @@
import re
from typing import Any
from sanic.base.meta import SanicMeta
from sanic.exceptions import SanicException
from sanic.mixins.commands import CommandMixin
from sanic.mixins.exceptions import ExceptionMixin
from sanic.mixins.listeners import ListenerMixin
from sanic.mixins.middleware import MiddlewareMixin
from sanic.mixins.routes import RouteMixin
from sanic.mixins.signals import SignalMixin
from sanic.mixins.static import StaticMixin
VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
class BaseSanic(
RouteMixin,
StaticMixin,
MiddlewareMixin,
ListenerMixin,
ExceptionMixin,
SignalMixin,
CommandMixin,
metaclass=SanicMeta,
):
__slots__ = ("name",)
def __init__(
self, name: str | None = None, *args: Any, **kwargs: Any
) -> None:
class_name = self.__class__.__name__
if name is None:
raise SanicException(
f"{class_name} instance cannot be unnamed. "
"Please use Sanic(name='your_application_name') instead.",
)
if not VALID_NAME.match(name):
raise SanicException(
f"{class_name} instance named '{name}' uses an invalid "
"format. Names must begin with a character and may only "
"contain alphanumeric characters, _, or -."
)
self.name = name
for base in BaseSanic.__bases__:
base.__init__(self, *args, **kwargs) # type: ignore
def __str__(self) -> str:
return f"<{self.__class__.__name__} {self.name}>"
def __repr__(self) -> str:
return f'{self.__class__.__name__}(name="{self.name}")'
def __setattr__(self, name: str, value: Any) -> None:
try:
super().__setattr__(name, value)
except AttributeError as e:
raise AttributeError(
f"Setting variables on {self.__class__.__name__} instances is "
"not allowed. You should change your "
f"{self.__class__.__name__} instance to use "
f"instance.ctx.{name} instead.",
) from e
@@ -0,0 +1,4 @@
from .blueprints import BlueprintGroup
__all__ = ["BlueprintGroup"] # noqa: F405
@@ -0,0 +1,983 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Iterable, Iterator, MutableSequence, Sequence
from copy import deepcopy
from functools import partial, wraps
from inspect import isfunction
from itertools import chain
from types import SimpleNamespace
from typing import (
TYPE_CHECKING,
Any,
Callable,
overload,
)
from sanic_routing.exceptions import NotFound
from sanic_routing.route import Route
from sanic.base.root import BaseSanic
from sanic.exceptions import SanicException
from sanic.helpers import Default, _default
from sanic.models.futures import FutureRoute, FutureSignal, FutureStatic
from sanic.models.handler_types import (
ListenerType,
MiddlewareType,
RouteHandler,
)
if TYPE_CHECKING:
from sanic import Sanic
def lazy(func, as_decorator=True):
"""Decorator to register a function to be called later.
Args:
func (Callable): Function to be called later.
as_decorator (bool): Whether the function should be called
immediately or not.
"""
@wraps(func)
def decorator(bp, *args, **kwargs):
nonlocal as_decorator
kwargs["apply"] = False
pass_handler = None
if args and isfunction(args[0]):
as_decorator = False
def wrapper(handler):
future = func(bp, *args, **kwargs)
if as_decorator:
future = future(handler)
if bp.registered:
for app in bp.apps:
bp.register(app, {})
return future
return wrapper if as_decorator else wrapper(pass_handler)
return decorator
class Blueprint(BaseSanic):
"""A logical collection of URLs that consist of a similar logical domain.
A Blueprint object is the main tool for grouping functionality and similar endpoints. It allows the developer to
organize routes, exception handlers, middleware, and other web functionalities into separate, modular groups.
See [Blueprints](/en/guide/best-practices/blueprints) for more information.
Args:
name (str): The name of the blueprint.
url_prefix (Optional[str]): The URL prefix for all routes defined on this blueprint.
host (Optional[Union[List[str], str]]): Host or list of hosts that this blueprint should respond to.
version (Optional[Union[int, str, float]]): Version number of the API implemented by this blueprint.
strict_slashes (Optional[bool]): Whether or not the URL should end with a slash.
version_prefix (str): Prefix for the version. Default is "/v".
""" # noqa: E501
__slots__ = (
"_apps",
"_future_commands",
"_future_routes",
"_future_statics",
"_future_middleware",
"_future_listeners",
"_future_exceptions",
"_future_signals",
"_allow_route_overwrite",
"copied_from",
"ctx",
"exceptions",
"host",
"listeners",
"middlewares",
"routes",
"statics",
"strict_slashes",
"url_prefix",
"version",
"version_prefix",
"websocket_routes",
)
def __init__(
self,
name: str,
url_prefix: str | None = None,
host: list[str] | str | None = None,
version: int | str | float | None = None,
strict_slashes: bool | None = None,
version_prefix: str = "/v",
):
super().__init__(name=name)
self.reset()
self._allow_route_overwrite = False
self.copied_from = ""
self.ctx = SimpleNamespace()
self.host = host
self.strict_slashes = strict_slashes
self.url_prefix = (
url_prefix[:-1]
if url_prefix and url_prefix.endswith("/")
else url_prefix
)
self.version = version
self.version_prefix = version_prefix
def __repr__(self) -> str:
args = ", ".join(
[
f'{attr}="{getattr(self, attr)}"'
if isinstance(getattr(self, attr), str)
else f"{attr}={getattr(self, attr)}"
for attr in (
"name",
"url_prefix",
"host",
"version",
"strict_slashes",
)
]
)
return f"Blueprint({args})"
@property
def apps(self) -> set[Sanic]:
"""Get the set of apps that this blueprint is registered to.
Returns:
Set[Sanic]: Set of apps that this blueprint is registered to.
Raises:
SanicException: If the blueprint has not yet been registered to
an app.
"""
if not self._apps:
raise SanicException(
f"{self} has not yet been registered to an app"
)
return self._apps
@property
def registered(self) -> bool:
"""Check if the blueprint has been registered to an app.
Returns:
bool: `True` if the blueprint has been registered to an app,
`False` otherwise.
"""
return bool(self._apps)
exception = lazy(BaseSanic.exception)
listener = lazy(BaseSanic.listener)
middleware = lazy(BaseSanic.middleware)
route = lazy(BaseSanic.route)
signal = lazy(BaseSanic.signal)
static = lazy(BaseSanic.static, as_decorator=False)
def reset(self) -> None:
"""Reset the blueprint to its initial state."""
self._apps: set[Sanic] = set()
self._allow_route_overwrite = False
self.exceptions: list[RouteHandler] = []
self.listeners: dict[str, list[ListenerType[Any]]] = {}
self.middlewares: list[MiddlewareType] = []
self.routes: list[Route] = []
self.statics: list[RouteHandler] = []
self.websocket_routes: list[Route] = []
def copy(
self,
name: str,
url_prefix: str | Default | None = _default,
version: int | str | float | Default | None = _default,
version_prefix: str | Default = _default,
allow_route_overwrite: bool | Default = _default,
strict_slashes: bool | Default | None = _default,
with_registration: bool = True,
with_ctx: bool = False,
):
"""Copy a blueprint instance with some optional parameters to override the values of attributes in the old instance.
Args:
name (str): Unique name of the blueprint.
url_prefix (Optional[Union[str, Default]]): URL to be prefixed before all route URLs.
version (Optional[Union[int, str, float, Default]]): Blueprint version.
version_prefix (Union[str, Default]): The prefix of the version number shown in the URL.
allow_route_overwrite (Union[bool, Default]): Whether to allow route overwrite or not.
strict_slashes (Optional[Union[bool, Default]]): Enforce the API URLs are requested with a trailing "/*".
with_registration (bool): Whether to register the new blueprint instance with Sanic apps that were registered with the old instance or not. Default is `True`.
with_ctx (bool): Whether the ``ctx`` will be copied or not. Default is `False`.
Returns:
Blueprint: A new Blueprint instance with the specified attributes.
""" # noqa: E501
attrs_backup = {
"_apps": self._apps,
"routes": self.routes,
"websocket_routes": self.websocket_routes,
"middlewares": self.middlewares,
"exceptions": self.exceptions,
"listeners": self.listeners,
"statics": self.statics,
}
self.reset()
new_bp = deepcopy(self)
new_bp.name = name
new_bp.copied_from = self.name
if not isinstance(url_prefix, Default):
new_bp.url_prefix = url_prefix
if not isinstance(version, Default):
new_bp.version = version
if not isinstance(strict_slashes, Default):
new_bp.strict_slashes = strict_slashes
if not isinstance(version_prefix, Default):
new_bp.version_prefix = version_prefix
if not isinstance(allow_route_overwrite, Default):
new_bp._allow_route_overwrite = allow_route_overwrite
for key, value in attrs_backup.items():
setattr(self, key, value)
if with_registration and self._apps:
if new_bp._future_statics:
raise SanicException(
"Static routes registered with the old blueprint instance,"
" cannot be registered again."
)
for app in self._apps:
app.blueprint(new_bp)
if not with_ctx:
new_bp.ctx = SimpleNamespace()
return new_bp
@staticmethod
def group(
*blueprints: Blueprint | BlueprintGroup,
url_prefix: str | None = None,
version: int | str | float | None = None,
strict_slashes: bool | None = None,
version_prefix: str = "/v",
name_prefix: str | None = "",
) -> BlueprintGroup:
"""Group multiple blueprints (or other blueprint groups) together.
Gropuping blueprings is a method for modularizing and organizing
your application's code. This can be a powerful tool for creating
reusable components, logically structuring your application code,
and easily maintaining route definitions in bulk.
This is the preferred way to group multiple blueprints together.
Args:
blueprints (Union[Blueprint, BlueprintGroup]): Blueprints to be
registered as a group.
url_prefix (Optional[str]): URL route to be prepended to all
sub-prefixes. Default is `None`.
version (Optional[Union[int, str, float]]): API Version to be
used for Blueprint group. Default is `None`.
strict_slashes (Optional[bool]): Indicate strict slash
termination behavior for URL. Default is `None`.
version_prefix (str): Prefix to be used for the version in the
URL. Default is "/v".
name_prefix (Optional[str]): Prefix to be used for the name of
the blueprints in the group. Default is an empty string.
Returns:
BlueprintGroup: A group of blueprints.
Example:
The resulting group will have the URL prefixes
`'/v2/bp1'` and `'/v2/bp2'` for bp1 and bp2, respectively.
```python
bp1 = Blueprint('bp1', url_prefix='/bp1')
bp2 = Blueprint('bp2', url_prefix='/bp2')
group = group(bp1, bp2, version=2)
```
"""
def chain(nested) -> Iterable[Blueprint]:
"""Iterate through nested blueprints"""
for i in nested:
if isinstance(i, (list, tuple)):
yield from chain(i)
else:
yield i
bps = BlueprintGroup(
url_prefix=url_prefix,
version=version,
strict_slashes=strict_slashes,
version_prefix=version_prefix,
name_prefix=name_prefix,
)
for bp in chain(blueprints):
bps.append(bp)
return bps
def register(self, app, options):
"""Register the blueprint to the sanic app.
Args:
app (Sanic): Sanic app to register the blueprint to.
options (dict): Options to be passed to the blueprint.
"""
self._apps.add(app)
url_prefix = options.get("url_prefix", self.url_prefix)
opt_version = options.get("version", None)
opt_strict_slashes = options.get("strict_slashes", None)
opt_version_prefix = options.get("version_prefix", self.version_prefix)
opt_name_prefix = options.get("name_prefix", None)
error_format = options.get(
"error_format", app.config.FALLBACK_ERROR_FORMAT
)
routes = []
middleware = []
exception_handlers = []
listeners = defaultdict(list)
registered = set()
# Routes
for future in self._future_routes:
# Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix)
route_error_format = (
future.error_format if future.error_format else error_format
)
version_prefix = self.version_prefix
for prefix in (
future.version_prefix,
opt_version_prefix,
):
if prefix and prefix != "/v":
version_prefix = prefix
break
version = self._extract_value(
future.version, opt_version, self.version
)
strict_slashes = self._extract_value(
future.strict_slashes, opt_strict_slashes, self.strict_slashes
)
name = future.name
if opt_name_prefix:
name = f"{opt_name_prefix}_{future.name}"
name = app.generate_name(name)
host = future.host or self.host
if isinstance(host, list):
host = tuple(host)
apply_route = FutureRoute(
future.handler,
uri,
future.methods,
host,
strict_slashes,
future.stream,
version,
name,
future.ignore_body,
future.websocket,
future.subprotocols,
future.unquote,
future.static,
version_prefix,
route_error_format,
future.route_context,
)
if (self, apply_route) in app._future_registry:
continue
registered.add(apply_route)
route = app._apply_route(
apply_route, overwrite=self._allow_route_overwrite
)
# If it is a copied BP, then make sure all of the names of routes
# matchup with the new BP name
if self.copied_from:
for r in route:
r.name = r.name.replace(self.copied_from, self.name)
r.extra.ident = r.extra.ident.replace(
self.copied_from, self.name
)
operation = (
routes.extend if isinstance(route, list) else routes.append
)
operation(route)
# Static Files
for future in self._future_statics:
# Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix)
apply_route = FutureStatic(uri, *future[1:])
if (self, apply_route) in app._future_registry:
continue
registered.add(apply_route)
route = app._apply_static(apply_route)
routes.append(route)
route_names = [route.name for route in routes if route]
if route_names:
# Middleware
for future in self._future_middleware:
if (self, future) in app._future_registry:
continue
middleware.append(app._apply_middleware(future, route_names))
# Exceptions
for future in self._future_exceptions:
if (self, future) in app._future_registry:
continue
exception_handlers.append(
app._apply_exception_handler(future, route_names)
)
# Event listeners
for future in self._future_listeners:
if (self, future) in app._future_registry:
continue
listeners[future.event].append(app._apply_listener(future))
# Signals
for future in self._future_signals:
if (self, future) in app._future_registry:
continue
future.condition.update({"__blueprint__": self.name})
# Force exclusive to be False
app._apply_signal(
FutureSignal(
future.handler,
future.event,
future.condition,
False,
future.priority,
)
)
self.routes += [route for route in routes if isinstance(route, Route)]
self.websocket_routes += [
route for route in self.routes if route.extra.websocket
]
self.middlewares += middleware
self.exceptions += exception_handlers
self.listeners.update(dict(listeners))
if self.registered:
self.register_futures(
self.apps,
self,
chain(
registered,
self._future_middleware,
self._future_exceptions,
self._future_listeners,
self._future_signals,
),
)
if self._future_commands:
raise SanicException(
"Registering commands with blueprints is not supported."
)
async def dispatch(self, *args, **kwargs):
"""Dispatch a signal event
Args:
*args: Arguments to be passed to the signal event.
**kwargs: Keyword arguments to be passed to the signal event.
"""
condition = kwargs.pop("condition", {})
condition.update({"__blueprint__": self.name})
kwargs["condition"] = condition
return await asyncio.gather(
*[app.dispatch(*args, **kwargs) for app in self.apps]
)
def event(
self,
event: str,
timeout: int | float | None = None,
*,
condition: dict[str, Any] | None = None,
):
"""Wait for a signal event to be dispatched.
Args:
event (str): Name of the signal event.
timeout (Optional[Union[int, float]]): Timeout for the event to be
dispatched.
condition: If provided, method will only return when the signal
is dispatched with the given condition.
Returns:
Awaitable: Awaitable for the event to be dispatched.
"""
if condition is None:
condition = {}
condition.update({"__blueprint__": self.name})
waiters = []
for app in self.apps:
waiter = app.signal_router.get_waiter(
event, condition, exclusive=False
)
if not waiter:
raise NotFound("Could not find signal %s" % event)
waiters.append(waiter)
return self._event(waiters, timeout)
async def _event(self, waiters, timeout):
done, pending = await asyncio.wait(
[asyncio.create_task(waiter.wait()) for waiter in waiters],
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout,
)
for task in pending:
task.cancel()
if not done:
raise TimeoutError()
(finished_task,) = done
return finished_task.result()
@staticmethod
def _extract_value(*values):
value = values[-1]
for v in values:
if v is not None:
value = v
break
return value
@staticmethod
def _setup_uri(base: str, prefix: str | None):
uri = base
if prefix:
uri = prefix
if base.startswith("/") and prefix.endswith("/"):
uri += base[1:]
else:
uri += base
return uri[1:] if uri.startswith("//") else uri
@staticmethod
def register_futures(
apps: set[Sanic], bp: Blueprint, futures: Sequence[tuple[Any, ...]]
):
"""Register futures to the apps.
Args:
apps (Set[Sanic]): Set of apps to register the futures to.
bp (Blueprint): Blueprint that the futures belong to.
futures (Sequence[Tuple[Any, ...]]): Sequence of futures to be
registered.
"""
for app in apps:
app._future_registry.update({(bp, item) for item in futures})
bpg_base = MutableSequence[Blueprint]
class BlueprintGroup(bpg_base):
"""This class provides a mechanism to implement a Blueprint Group.
The `BlueprintGroup` class allows grouping blueprints under a common
URL prefix, version, and other shared attributes. It integrates with
Sanic's Blueprint system, offering a custom iterator to treat an
object of this class as a list/tuple.
Although possible to instantiate a group directly, it is recommended
to use the `Blueprint.group` method to create a group of blueprints.
Args:
url_prefix (Optional[str]): URL to be prefixed before all the
Blueprint Prefixes. Default is `None`.
version (Optional[Union[int, str, float]]): API Version for the
blueprint group, inherited by each Blueprint. Default is `None`.
strict_slashes (Optional[bool]): URL Strict slash behavior
indicator. Default is `None`.
version_prefix (str): Prefix for the version in the URL.
Default is `"/v"`.
name_prefix (Optional[str]): Prefix for the name of the blueprints
in the group. Default is an empty string.
Examples:
```python
bp1 = Blueprint("bp1", url_prefix="/bp1")
bp2 = Blueprint("bp2", url_prefix="/bp2")
bp3 = Blueprint("bp3", url_prefix="/bp4")
bp4 = Blueprint("bp3", url_prefix="/bp4")
group1 = Blueprint.group(bp1, bp2)
group2 = Blueprint.group(bp3, bp4, version_prefix="/api/v", version="1")
@bp1.on_request
async def bp1_only_middleware(request):
print("applied on Blueprint : bp1 Only")
@bp1.route("/")
async def bp1_route(request):
return text("bp1")
@bp2.route("/<param>")
async def bp2_route(request, param):
return text(param)
@bp3.route("/")
async def bp3_route(request):
return text("bp3")
@bp4.route("/<param>")
async def bp4_route(request, param):
return text(param)
@group1.on_request
async def group_middleware(request):
print("common middleware applied for both bp1 and bp2")
# Register Blueprint group under the app
app.blueprint(group1)
app.blueprint(group2)
```
""" # noqa: E501
__slots__ = (
"_blueprints",
"_url_prefix",
"_version",
"_strict_slashes",
"_version_prefix",
"_name_prefix",
)
def __init__(
self,
url_prefix: str | None = None,
version: int | str | float | None = None,
strict_slashes: bool | None = None,
version_prefix: str = "/v",
name_prefix: str | None = "",
):
self._blueprints: list[Blueprint] = []
self._url_prefix = url_prefix
self._version = version
self._version_prefix = version_prefix
self._strict_slashes = strict_slashes
self._name_prefix = name_prefix
@property
def url_prefix(self) -> int | str | float | None:
"""The URL prefix for the Blueprint Group.
Returns:
Optional[Union[int, str, float]]: URL prefix for the Blueprint
Group.
"""
return self._url_prefix
@property
def blueprints(self) -> list[Blueprint]:
"""A list of all the available blueprints under this group.
Returns:
List[Blueprint]: List of all the available blueprints under
this group.
"""
return self._blueprints
@property
def version(self) -> str | int | float | None:
"""API Version for the Blueprint Group, if any.
Returns:
Optional[Union[str, int, float]]: API Version for the Blueprint
"""
return self._version
@property
def strict_slashes(self) -> bool | None:
"""Whether to enforce strict slashes for the Blueprint Group.
Returns:
Optional[bool]: Whether to enforce strict slashes for the
"""
return self._strict_slashes
@property
def version_prefix(self) -> str:
"""Version prefix for the Blueprint Group.
Returns:
str: Version prefix for the Blueprint Group.
"""
return self._version_prefix
@property
def name_prefix(self) -> str | None:
"""Name prefix for the Blueprint Group.
This is mainly needed when blueprints are copied in order to
avoid name conflicts.
Returns:
Optional[str]: Name prefix for the Blueprint Group.
"""
return self._name_prefix
def __iter__(self) -> Iterator[Blueprint]:
"""Iterate over the list of blueprints in the group.
Returns:
Iterator[Blueprint]: Iterator for the list of blueprints in
"""
return iter(self._blueprints)
@overload
def __getitem__(self, item: int) -> Blueprint: ...
@overload
def __getitem__(self, item: slice) -> MutableSequence[Blueprint]: ...
def __getitem__(
self, item: int | slice
) -> Blueprint | MutableSequence[Blueprint]:
"""Get the Blueprint object at the specified index.
This method returns a blueprint inside the group specified by
an index value. This will enable indexing, splice and slicing
of the blueprint group like we can do with regular list/tuple.
This method is provided to ensure backward compatibility with
any of the pre-existing usage that might break.
Returns:
Blueprint: Blueprint object at the specified index.
Raises:
IndexError: If the index is out of range.
"""
return self._blueprints[item]
@overload
def __setitem__(self, index: int, item: Blueprint) -> None: ...
@overload
def __setitem__(self, index: slice, item: Iterable[Blueprint]) -> None: ...
def __setitem__(
self,
index: int | slice,
item: Blueprint | Iterable[Blueprint],
) -> None:
"""Set the Blueprint object at the specified index.
Abstract method implemented to turn the `BlueprintGroup` class
into a list like object to support all the existing behavior.
This method is used to perform the list's indexed setter operation.
Args:
index (int): Index to use for removing a new Blueprint item
item (Blueprint): New `Blueprint` object.
Returns:
None
Raises:
IndexError: If the index is out of range.
"""
if isinstance(index, int):
if not isinstance(item, Blueprint):
raise TypeError("Expected a Blueprint instance")
self._blueprints[index] = item
elif isinstance(index, slice):
if not isinstance(item, Iterable):
raise TypeError("Expected an iterable of Blueprint instances")
self._blueprints[index] = list(item)
else:
raise TypeError("Index must be int or slice")
@overload
def __delitem__(self, index: int) -> None: ...
@overload
def __delitem__(self, index: slice) -> None: ...
def __delitem__(self, index: int | slice) -> None:
"""Delete the Blueprint object at the specified index.
Abstract method implemented to turn the `BlueprintGroup` class
into a list like object to support all the existing behavior.
This method is used to delete an item from the list of blueprint
groups like it can be done on a regular list with index.
Args:
index (int): Index to use for removing a new Blueprint item
Returns:
None
Raises:
IndexError: If the index is out of range.
"""
del self._blueprints[index]
def __len__(self) -> int:
"""Get the Length of the blueprint group object.
Returns:
int: Length of the blueprint group object.
"""
return len(self._blueprints)
def append(self, value: Blueprint) -> None:
"""Add a new Blueprint object to the group.
The Abstract class `MutableSequence` leverages this append method to
perform the `BlueprintGroup.append` operation.
Args:
value (Blueprint): New `Blueprint` object.
Returns:
None
"""
self._blueprints.append(value)
def exception(self, *exceptions: Exception, **kwargs) -> Callable:
"""Decorate a function to handle exceptions for all blueprints in the group.
In case of nested Blueprint Groups, the same handler is applied
across each of the Blueprints recursively.
Args:
*exceptions (Exception): Exceptions to handle
**kwargs (dict): Optional Keyword arg to use with Middleware
Returns:
Partial function to apply the middleware
Examples:
```python
bp1 = Blueprint("bp1", url_prefix="/bp1")
bp2 = Blueprint("bp2", url_prefix="/bp2")
group1 = Blueprint.group(bp1, bp2)
@group1.exception(Exception)
def handler(request, exception):
return text("Exception caught")
```
""" # noqa: E501
def register_exception_handler_for_blueprints(fn):
for blueprint in self.blueprints:
blueprint.exception(*exceptions, **kwargs)(fn)
return register_exception_handler_for_blueprints
def insert(self, index: int, item: Blueprint) -> None:
"""Insert a new Blueprint object to the group at the specified index.
The Abstract class `MutableSequence` leverages this insert method to
perform the `BlueprintGroup.append` operation.
Args:
index (int): Index to use for removing a new Blueprint item
item (Blueprint): New `Blueprint` object.
Returns:
None
"""
self._blueprints.insert(index, item)
def middleware(self, *args, **kwargs):
"""A decorator that can be used to implement a Middleware for all blueprints in the group.
In case of nested Blueprint Groups, the same middleware is applied
across each of the Blueprints recursively.
Args:
*args (Optional): Optional positional Parameters to be use middleware
**kwargs (Optional): Optional Keyword arg to use with Middleware
Returns:
Partial function to apply the middleware
""" # noqa: E501
def register_middleware_for_blueprints(fn):
for blueprint in self.blueprints:
blueprint.middleware(fn, *args, **kwargs)
if args and callable(args[0]):
fn = args[0]
args = list(args)[1:]
return register_middleware_for_blueprints(fn)
return register_middleware_for_blueprints
def on_request(self, middleware=None):
"""Convenience method to register a request middleware for all blueprints in the group.
Args:
middleware (Optional): Optional positional Parameters to be use middleware
Returns:
Partial function to apply the middleware
""" # noqa: E501
if callable(middleware):
return self.middleware(middleware, "request")
else:
return partial(self.middleware, attach_to="request")
def on_response(self, middleware=None):
"""Convenience method to register a response middleware for all blueprints in the group.
Args:
middleware (Optional): Optional positional Parameters to be use middleware
Returns:
Partial function to apply the middleware
""" # noqa: E501
if callable(middleware):
return self.middleware(middleware, "response")
else:
return partial(self.middleware, attach_to="response")
@@ -0,0 +1,499 @@
from __future__ import annotations
import os
import shutil
import sys
from argparse import Namespace
from functools import partial
from textwrap import indent
from sanic.app import Sanic
from sanic.application.logo import get_logo
from sanic.cli.arguments import Group
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.console import SanicREPL
from sanic.cli.daemon import (
kill_daemon,
make_kill_parser,
make_restart_parser,
make_status_parser,
resolve_target,
restart_daemon,
status_daemon,
stop_daemon,
)
from sanic.cli.executor import Executor, make_executor_parser
from sanic.cli.inspector import make_inspector_parser
from sanic.cli.inspector_client import InspectorClient
from sanic.compat import OS_IS_WINDOWS
from sanic.helpers import _default, is_atty
from sanic.log import error_logger
from sanic.worker.daemon import Daemon, DaemonError
from sanic.worker.loader import AppLoader
class SanicCLI:
DESCRIPTION_SHORT = indent(
f"""
{get_logo(True)}
Usage:
sanic <target> [options] Run a Sanic application
sanic <target> exec <cmd> Execute a command in app context
sanic inspect [options] Inspect a running instance
sanic help [--full] Show help (--full for all options)
Examples:
sanic path.to.server:app Run app
sanic path.to.server --dev Run in development mode
sanic ./static --simple Serve static files
""",
prefix=" ",
)
DESCRIPTION_SHORT_FOOTER = """
(additional options available)
For complete options and documentation:
sanic help --full
"""
DESCRIPTION_FULL = indent(
f"""
{get_logo(True)}
To start running a Sanic application, provide a path to the module, where
app is a Sanic() instance in the global scope:
$ sanic path.to.server:app
If the Sanic instance variable is called 'app', you can leave off the last
part, and only provide a path to the module where the instance is:
$ sanic path.to.server
Or, a path to a callable that returns a Sanic() instance:
$ sanic path.to.factory:create_app
Or, a path to a directory to run as a simple HTTP server:
$ sanic ./path/to/static
Additional commands:
$ sanic inspect ... Inspect a running Sanic instance
$ sanic path.to.app exec ... Run app commands
$ sanic path.to.app status Check if app daemon is running
$ sanic path.to.app restart Restart app daemon (future use)
$ sanic path.to.app stop Stop app daemon
Advanced daemon management:
$ sanic kill (--pid PID | --pidfile PATH) Force kill (SIGKILL)
$ sanic status (--pid PID | --pidfile PATH) Check status
$ sanic restart (--pid PID | --pidfile PATH) Restart (future use)
""",
prefix=" ",
)
def __init__(self) -> None:
width = shutil.get_terminal_size().columns
self.parser = SanicArgumentParser(
prog="sanic",
description=self.DESCRIPTION_SHORT,
formatter_class=lambda prog: SanicHelpFormatter(
prog,
max_help_position=36 if width > 96 else 24,
indent_increment=4,
width=None,
),
)
self.parser._positionals.title = "Required\n========\n Positional"
self.parser._optionals.title = "Optional\n========\n General"
self.main_process = (
os.environ.get("SANIC_RELOADER_PROCESS", "") != "true"
)
self.args: Namespace = Namespace()
self.groups: list[Group] = []
self.run_mode = "serve"
def attach(self):
if len(sys.argv) > 1 and sys.argv[1] == "help":
self.run_mode = "help"
return
if len(sys.argv) == 2 and sys.argv[1] in ("-h", "--help"):
self.run_mode = "help"
return
if len(sys.argv) > 1 and sys.argv[1] == "inspect":
self.run_mode = "inspect"
self.parser.description = get_logo(True)
make_inspector_parser(self.parser)
return
if not OS_IS_WINDOWS and len(sys.argv) > 1 and sys.argv[1] == "kill":
self.run_mode = "kill"
self.parser.description = get_logo(True)
make_kill_parser(self.parser)
return
if not OS_IS_WINDOWS and len(sys.argv) > 1 and sys.argv[1] == "status":
self.run_mode = "status"
self.parser.description = get_logo(True)
make_status_parser(self.parser)
return
if (
not OS_IS_WINDOWS
and len(sys.argv) > 1
and sys.argv[1] == "restart"
):
self.run_mode = "restart"
self.parser.description = get_logo(True)
make_restart_parser(self.parser)
return
# Check for app-based daemon commands: sanic <app> status|restart|stop
if (
not OS_IS_WINDOWS
and len(sys.argv) > 2
and sys.argv[2]
in (
"status",
"restart",
"stop",
)
):
self.run_mode = f"app_{sys.argv[2]}"
for group in Group._registry:
instance = group.create(self.parser)
instance.attach()
self.groups.append(instance)
if len(sys.argv) > 2 and sys.argv[2] == "exec":
self.run_mode = "exec"
self.parser.description = get_logo(True)
make_executor_parser(self.parser)
def run(self, parse_args=None):
if self.run_mode == "inspect":
self._inspector()
return
if self.run_mode == "kill":
self._kill()
return
if self.run_mode == "status":
self._status()
return
if self.run_mode == "restart":
self._restart()
return
if self.run_mode == "help":
self._help()
return
if self.run_mode.startswith("app_"):
self._app_daemon_command()
return
legacy_version = False
if not parse_args:
# This is to provide backwards compat -v to display version
legacy_version = len(sys.argv) == 2 and sys.argv[-1] == "-v"
parse_args = ["--version"] if legacy_version else None
elif parse_args == ["-v"]:
parse_args = ["--version"]
if not legacy_version:
if self.run_mode == "exec":
parse_args = [
a
for a in (parse_args or sys.argv[1:])
if a not in "-h --help".split()
]
parsed, unknown = self.parser.parse_known_args(args=parse_args)
if unknown and parsed.factory:
for arg in unknown:
if arg.startswith("--"):
self.parser.add_argument(arg.split("=")[0])
if self.run_mode == "exec":
self.args, _ = self.parser.parse_known_args(args=parse_args)
else:
self.args = self.parser.parse_args(args=parse_args)
self._precheck()
app_loader = AppLoader(
self.args.target, self.args.factory, self.args.simple, self.args
)
try:
app = self._get_app(app_loader)
kwargs = self._build_run_kwargs()
except ValueError as e:
error_logger.exception(f"Failed to run app: {e}")
else:
if self.run_mode == "exec":
self._executor(app, kwargs)
return
elif self.run_mode != "serve":
raise ValueError(f"Unknown run mode: {self.run_mode}")
daemon = None
if getattr(self.args, "daemon", False):
daemon = self._setup_daemon(app.name)
if daemon:
lines = ["Starting Sanic in daemon mode..."]
if daemon.pidfile:
lines.append(f" PID file: {daemon.pidfile}")
if daemon.logfile:
lines.append(f" Logs: {daemon.logfile}")
print("\n".join(lines), flush=True)
daemon.daemonize()
if self.args.repl:
self._repl(app)
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
if daemon:
daemon.drop_privileges()
if self.args.single:
serve = Sanic.serve_single
else:
serve = partial(Sanic.serve, app_loader=app_loader)
serve(app)
def _inspector(self):
args = sys.argv[2:]
self.args, unknown = self.parser.parse_known_args(args=args)
if unknown:
for arg in unknown:
if arg.startswith("--"):
try:
key, value = arg.split("=")
key = key.lstrip("-")
except ValueError:
value = False if arg.startswith("--no-") else True
key = (
arg.replace("--no-", "")
.lstrip("-")
.replace("-", "_")
)
setattr(self.args, key, value)
kwargs = {**self.args.__dict__}
host = kwargs.pop("host")
port = kwargs.pop("port")
secure = kwargs.pop("secure")
raw = kwargs.pop("raw")
action = kwargs.pop("action") or "info"
api_key = kwargs.pop("api_key")
positional = kwargs.pop("positional", None)
if action == "<custom>" and positional:
action = positional[0]
if len(positional) > 1:
kwargs["args"] = positional[1:]
InspectorClient(host, port, secure, raw, api_key).do(action, **kwargs)
def _kill(self):
self.args = self.parser.parse_args(args=sys.argv[2:])
pid, pidfile = resolve_target(self.args.pid, self.args.pidfile)
kill_daemon(pid, pidfile)
def _status(self):
self.args = self.parser.parse_args(args=sys.argv[2:])
pid, pidfile = resolve_target(self.args.pid, self.args.pidfile)
status_daemon(pid, pidfile)
def _restart(self):
self.args = self.parser.parse_args(args=sys.argv[2:])
pid, _ = resolve_target(self.args.pid, self.args.pidfile)
restart_daemon(pid)
def _help(self):
full = "--full" in sys.argv
if full:
self.parser.description = self.DESCRIPTION_FULL
for group in Group._registry:
instance = group.create(self.parser)
instance.attach(short=not full)
self.groups.append(instance)
self.parser.print_help()
if not full:
print(self.DESCRIPTION_SHORT_FOOTER)
def _app_daemon_command(self):
"""Handle app-based daemon commands: sanic <app> status|restart|stop"""
command = self.run_mode.replace("app_", "")
# Parse just the app target (first arg)
self.args = self.parser.parse_args(args=[sys.argv[1]])
app_loader = AppLoader(
self.args.target, self.args.factory, self.args.simple, self.args
)
try:
app = self._get_app(app_loader)
except (ImportError, ValueError) as e:
error_logger.error(f"Failed to load app: {e}")
sys.exit(1)
pidfile = Daemon.get_pidfile_path(app.name)
pid, pidfile = resolve_target(None, str(pidfile))
if command == "status":
status_daemon(pid, pidfile)
elif command == "restart":
restart_daemon(pid)
elif command == "stop":
force = "-f" in sys.argv or "--force" in sys.argv
stop_daemon(pid, pidfile, force)
def _executor(self, app: Sanic, kwargs: dict):
args = sys.argv[3:]
Executor(app, kwargs).run(self.args.command, args)
def _repl(self, app: Sanic):
if is_atty():
@app.main_process_ready
async def start_repl(app):
SanicREPL(app, self.args.repl).run()
await app._startup()
elif self.args.repl is True:
error_logger.error(
"Can't start REPL in non-interactive mode. "
"You can only run with --repl in a TTY."
)
def _setup_daemon(self, app_name: str):
if OS_IS_WINDOWS:
error_logger.error(
"Daemon mode is not supported on Windows. "
"Consider using a Windows service or nssm instead."
)
sys.exit(1)
if getattr(self.args, "dev", False) or getattr(
self.args, "auto_reload", False
):
error_logger.error(
"Daemon mode is not compatible with --dev or --auto-reload"
)
sys.exit(1)
if getattr(self.args, "repl", False):
error_logger.error("Daemon mode is not compatible with --repl")
sys.exit(1)
try:
daemon = Daemon(
pidfile=getattr(self.args, "pidfile", None) or "auto",
logfile=getattr(self.args, "logfile", None),
user=getattr(self.args, "daemon_user", None),
group=getattr(self.args, "daemon_group", None),
name=app_name,
)
return daemon
except DaemonError as e:
error_logger.error(f"Daemon configuration error: {e}")
sys.exit(1)
def _precheck(self):
# Custom TLS mismatch handling for better diagnostics
if self.main_process and (
# one of cert/key missing
bool(self.args.cert) != bool(self.args.key)
# new and old style self.args used together
or self.args.tls
and self.args.cert
# strict host checking without certs would always fail
or self.args.tlshost
and not self.args.tls
and not self.args.cert
):
self.parser.print_usage(sys.stderr)
message = (
"TLS certificates must be specified by either of:\n"
" --cert certdir/fullchain.pem --key certdir/privkey.pem\n"
" --tls certdir (equivalent to the above)"
)
error_logger.error(message)
sys.exit(1)
def _get_app(self, app_loader: AppLoader):
try:
app = app_loader.load()
except ImportError as e:
if app_loader.module_name.startswith(e.name): # type: ignore
error_logger.error(
f"No module named {e.name} found.\n"
" Example File: project/sanic_server.py -> app\n"
" Example Module: project.sanic_server.app"
)
error_logger.error(
"\nThe error below might have caused the above one:\n"
f"{e.msg}"
)
sys.exit(1)
else:
raise e
return app
def _build_run_kwargs(self):
for group in self.groups:
group.prepare(self.args)
ssl: None | dict | str | list = []
if self.args.tlshost:
ssl.append(None)
if self.args.cert is not None or self.args.key is not None:
ssl.append(dict(cert=self.args.cert, key=self.args.key))
if self.args.tls:
ssl += self.args.tls
if not ssl:
ssl = None
elif len(ssl) == 1 and ssl[0] is not None:
# Use only one cert, no TLSSelector.
ssl = ssl[0]
kwargs = {
"access_log": self.args.access_log,
"coffee": self.args.coffee,
"debug": self.args.debug,
"fast": self.args.fast,
"host": self.args.host,
"motd": self.args.motd,
"noisy_exceptions": self.args.noisy_exceptions,
"port": self.args.port,
"ssl": ssl,
"unix": self.args.unix,
"verbosity": self.args.verbosity or 0,
"workers": self.args.workers,
"auto_tls": self.args.auto_tls,
"single_process": self.args.single,
}
for maybe_arg in ("auto_reload", "dev"):
if getattr(self.args, maybe_arg, False):
kwargs[maybe_arg] = True
if self.args.dev and all(
arg not in sys.argv for arg in ("--repl", "--no-repl")
):
self.args.repl = _default
if self.args.path:
kwargs["auto_reload"] = True
kwargs["reload_dir"] = self.args.path
return kwargs
@@ -0,0 +1,431 @@
from __future__ import annotations
from argparse import ArgumentParser, _ArgumentGroup
from sanic_routing import __version__ as __routing_version__
from sanic import __version__
from sanic.compat import OS_IS_WINDOWS
from sanic.http.constants import HTTP
class Group:
name: str | None
container: ArgumentParser | _ArgumentGroup
_registry: list[type[Group]] = []
def __init_subclass__(cls) -> None:
Group._registry.append(cls)
def __init__(self, parser: ArgumentParser, title: str | None):
self.parser = parser
if title:
self.container = self.parser.add_argument_group(title=f" {title}")
else:
self.container = self.parser
@classmethod
def create(cls, parser: ArgumentParser):
instance = cls(parser, cls.name)
return instance
def add_bool_arguments(
self,
*args,
nullable=False,
help: str,
negative_help: str | None = None,
**kwargs,
):
group = self.container.add_mutually_exclusive_group()
pos_help = help[0].upper() + help[1:]
neg_help = (
negative_help if negative_help else f"Disable {help.lower()}"
)
group.add_argument(
*args,
action="store_true",
help=pos_help,
**kwargs,
)
group.add_argument(
"--no-" + args[0][2:],
*args[1:],
action="store_false",
help=neg_help[0].upper() + neg_help[1:],
**kwargs,
)
if nullable:
group.set_defaults(**{args[0][2:].replace("-", "_"): None})
def prepare(self, args) -> None: ...
def attach(self, short: bool = False) -> None: ...
class GeneralGroup(Group):
name = None
def attach(self, short: bool = False):
if short:
return
self.container.add_argument(
"--version",
action="version",
version=f"Sanic {__version__}; Routing {__routing_version__}",
)
self.container.add_argument(
"target",
help=(
"Path to your Sanic app instance.\n"
"\tExample: path.to.server:app\n"
"If running a Simple Server, path to directory to serve.\n"
"\tExample: ./\n"
"Additionally, this can be a path to a factory function\n"
"that returns a Sanic app instance.\n"
"\tExample: path.to.server:create_app\n"
),
)
choices = ["serve", "exec"]
help_text = (
"Action to perform.\n"
"\tserve: Run the Sanic app [default]\n"
"\texec: Execute a command in the Sanic app context\n"
)
if not OS_IS_WINDOWS:
choices.extend(["status", "restart", "stop"])
help_text += (
"\tstatus: Check if daemon is running\n"
"\trestart: Restart daemon (future use)\n"
"\tstop: Stop daemon gracefully\n"
)
self.container.add_argument(
"action",
nargs="?",
default="serve",
choices=choices,
help=help_text,
)
class ApplicationGroup(Group):
name = "Application"
def attach(self, short: bool = False):
if short:
return
group = self.container.add_mutually_exclusive_group()
group.add_argument(
"--factory",
action="store_true",
help=(
"Treat app as an application factory, "
"i.e. a () -> <Sanic app> callable"
),
)
group.add_argument(
"-s",
"--simple",
dest="simple",
action="store_true",
help=(
"Run Sanic as a Simple Server, and serve the contents of "
"a directory\n(module arg should be a path)"
),
)
self.add_bool_arguments(
"--repl",
help="Run with an interactive shell session",
negative_help="Disable interactive shell session",
)
class SocketGroup(Group):
name = "Socket binding"
def attach(self, short: bool = False):
self.container.add_argument(
"-H",
"--host",
dest="host",
type=str,
help="Host address [default 127.0.0.1]",
)
self.container.add_argument(
"-p",
"--port",
dest="port",
type=int,
help="Port to serve on [default 8000]",
)
if not OS_IS_WINDOWS and not short:
self.container.add_argument(
"-u",
"--unix",
dest="unix",
type=str,
default="",
help="location of UNIX socket",
)
class HTTPVersionGroup(Group):
name = "HTTP version"
def attach(self, short: bool = False):
if short:
return
http_values = [http.value for http in HTTP.__members__.values()]
self.container.add_argument(
"--http",
dest="http",
action="append",
choices=http_values,
type=int,
help=(
"Which HTTP version to use: HTTP/1.1 or HTTP/3. Value should\n"
"be either 1, or 3. [default 1]"
),
)
self.container.add_argument(
"-1",
dest="http",
action="append_const",
const=1,
help=("Run Sanic server using HTTP/1.1"),
)
self.container.add_argument(
"-3",
dest="http",
action="append_const",
const=3,
help=("Run Sanic server using HTTP/3"),
)
def prepare(self, args):
if not args.http:
args.http = [1]
args.http = tuple(sorted(set(map(HTTP, args.http)), reverse=True))
class TLSGroup(Group):
name = "TLS certificate"
def attach(self, short: bool = False):
if short:
return
self.container.add_argument(
"--cert",
dest="cert",
type=str,
help="Location of fullchain.pem, bundle.crt or equivalent",
)
self.container.add_argument(
"--key",
dest="key",
type=str,
help="Location of privkey.pem or equivalent .key file",
)
self.container.add_argument(
"--tls",
metavar="DIR",
type=str,
action="append",
help=(
"TLS certificate folder with fullchain.pem and privkey.pem\n"
"May be specified multiple times to choose multiple "
"certificates"
),
)
self.container.add_argument(
"--tls-strict-host",
dest="tlshost",
action="store_true",
help="Only allow clients that send an SNI matching server certs",
)
class DevelopmentGroup(Group):
name = "Development"
def attach(self, short: bool = False):
if not short:
self.container.add_argument(
"--debug",
dest="debug",
action="store_true",
help="Run the server in debug mode",
)
self.container.add_argument(
"-r",
"--reload",
"--auto-reload",
dest="auto_reload",
action="store_true",
help="Auto-reload on source changes",
)
self.container.add_argument(
"-R",
"--reload-dir",
dest="path",
action="append",
help="Additional directories to watch for changes",
)
self.container.add_argument(
"-d",
"--dev",
dest="dev",
action="store_true",
help="Run in development mode (debug + auto-reload)",
)
if not short:
self.container.add_argument(
"--auto-tls",
dest="auto_tls",
action="store_true",
help=(
"Create a temporary TLS certificate for local development "
"(requires mkcert or trustme)"
),
)
class WorkerGroup(Group):
name = "Worker"
def attach(self, short: bool = False):
if short:
return
group = self.container.add_mutually_exclusive_group()
group.add_argument(
"-w",
"--workers",
dest="workers",
type=int,
default=1,
help="Number of worker processes [default 1]",
)
group.add_argument(
"--fast",
dest="fast",
action="store_true",
help="Set the number of workers to max allowed",
)
group.add_argument(
"--single-process",
dest="single",
action="store_true",
help="Do not use multiprocessing, run server in a single process",
)
self.add_bool_arguments(
"--access-logs",
dest="access_log",
help="display access logs",
default=None,
)
class OutputGroup(Group):
name = "Output"
def attach(self, short: bool = False):
if short:
return
self.add_bool_arguments(
"--coffee",
dest="coffee",
default=False,
help="Uhm, coffee?",
negative_help="No coffee? Is that a typo?",
)
self.add_bool_arguments(
"--motd",
dest="motd",
default=True,
help="Show the startup display",
negative_help="Disable the startup display",
)
self.container.add_argument(
"-v",
"--verbosity",
action="count",
help="Control logging noise, eg. -vv or --verbosity=2 [default 0]",
)
self.add_bool_arguments(
"--noisy-exceptions",
dest="noisy_exceptions",
help="Output stack traces for all exceptions",
default=None,
)
class DaemonGroup(Group):
name = "Daemon"
def attach(self, short: bool = False):
if OS_IS_WINDOWS:
return
self.container.add_argument(
"-D",
"--daemon",
dest="daemon",
action="store_true",
help="Run server in background (auto-generated PID file)",
)
if short:
return
self.container.add_argument(
"--pidfile",
dest="pidfile",
type=str,
default=None,
help="Override auto-generated PID file path (requires --daemon)",
)
self.container.add_argument(
"--logfile",
dest="logfile",
type=str,
default=None,
help="Path to log file for daemon output (requires --daemon)",
)
self.container.add_argument(
"--user",
dest="daemon_user",
type=str,
default=None,
help="User to run daemon as (requires root)",
)
self.container.add_argument(
"--group",
dest="daemon_group",
type=str,
default=None,
help="Group to run daemon as (requires root)",
)
def prepare(self, args):
if OS_IS_WINDOWS:
return
has_daemon_opts = getattr(args, "pidfile", None) or getattr(
args, "logfile", None
)
if has_daemon_opts and not getattr(args, "daemon", False):
raise SystemExit("Error: --pidfile and --logfile require --daemon")
@@ -0,0 +1,35 @@
from argparse import (
SUPPRESS,
Action,
ArgumentParser,
RawTextHelpFormatter,
_SubParsersAction,
)
from typing import Any
class SanicArgumentParser(ArgumentParser):
def _check_value(self, action: Action, value: Any) -> None:
if isinstance(action, SanicSubParsersAction):
return
super()._check_value(action, value)
class SanicHelpFormatter(RawTextHelpFormatter):
def add_usage(self, usage, actions, groups, prefix=None):
if not usage:
usage = SUPPRESS
# Add one linebreak, but not two
self.add_text("\x1b[1A")
super().add_usage(usage, actions, groups, prefix)
class SanicSubParsersAction(_SubParsersAction):
def __call__(self, parser, namespace, values, option_string=None):
self._name_parser_map
parser_name = values[0]
if parser_name not in self._name_parser_map:
self._name_parser_map[parser_name] = parser
values = ["<custom>", *values]
super().__call__(parser, namespace, values, option_string)
@@ -0,0 +1,309 @@
from __future__ import annotations
import atexit
import concurrent.futures
import sys
import threading
import time
import traceback
try:
import termios
TERMIOS_AVAILABLE = True
except ImportError:
TERMIOS_AVAILABLE = False
termios = None # type: ignore
from ast import PyCF_ALLOW_TOP_LEVEL_AWAIT
from asyncio import iscoroutine, new_event_loop
from code import InteractiveConsole
from collections.abc import Sequence
from contextlib import suppress
from types import FunctionType
from typing import Any, NamedTuple
import sanic
from sanic import Request, Sanic
from sanic.compat import Header
from sanic.helpers import Default
from sanic.http.constants import Stage
from sanic.log import Colors
from sanic.models.ctx_types import REPLContext
from sanic.models.protocol_types import TransportProtocol
from sanic.response.types import HTTPResponse
try:
from httpx import Client
HTTPX_AVAILABLE = True
class SanicClient(Client):
def __init__(self, app: Sanic):
base_url = app.get_server_location(
app.state.server_info[0].settings
)
super().__init__(base_url=base_url)
except ImportError:
HTTPX_AVAILABLE = False
try:
import readline # noqa
except ImportError:
print(
"Module 'readline' not available. History navigation will be limited.",
file=sys.stderr,
)
repl_app: Sanic | None = None
repl_response: HTTPResponse | None = None
class REPLProtocol(TransportProtocol):
def __init__(self):
self.stage = Stage.IDLE
self.request_body = True
def respond(self, response):
global repl_response
repl_response = response
response.stream = self
return response
async def send(self, data, end_stream): ...
class Result(NamedTuple):
request: Request
response: HTTPResponse
def make_request(
url: str = "/",
headers: dict[str, Any] | Sequence[tuple[str, str]] | None = None,
method: str = "GET",
body: str | None = None,
):
assert repl_app, "No Sanic app has been registered."
headers = headers or {}
protocol = REPLProtocol()
request = Request( # type: ignore
url.encode(),
Header(headers),
"1.1",
method,
protocol,
repl_app,
)
if body is not None:
request.body = body.encode()
request.stream = protocol # type: ignore
request.conn_info = None
return request
async def respond(request) -> HTTPResponse:
assert repl_app, "No Sanic app has been registered."
await repl_app.handle_request(request)
assert repl_response
return repl_response
async def do(
url: str = "/",
headers: dict[str, Any] | Sequence[tuple[str, str]] | None = None,
method: str = "GET",
body: str | None = None,
) -> Result:
request = make_request(url, headers, method, body)
response = await respond(request)
return Result(request, response)
def _variable_description(name: str, desc: str, type_desc: str) -> str:
return (
f" - {Colors.BOLD + Colors.SANIC}{name}{Colors.END}: {desc} - "
f"{Colors.BOLD + Colors.BLUE}{type_desc}{Colors.END}"
)
class SanicREPL(InteractiveConsole):
def __init__(self, app: Sanic, start: Default | None = None):
global repl_app
repl_app = app
locals_available = {
"app": app,
"sanic": sanic,
"do": do,
}
user_locals = {
user_local.name: user_local.var for user_local in app.repl_ctx
}
client_availability = ""
variable_descriptions = [
_variable_description(
"app", REPLContext.BUILTINS["app"], str(app)
),
_variable_description(
"sanic", REPLContext.BUILTINS["sanic"], "import sanic"
),
_variable_description(
"do", REPLContext.BUILTINS["do"], "Result(request, response)"
),
]
user_locals_descriptions = [
_variable_description(
user_local.name, user_local.desc, str(type(user_local.var))
)
for user_local in app.repl_ctx
]
if HTTPX_AVAILABLE:
locals_available["client"] = SanicClient(app)
variable_descriptions.append(
_variable_description(
"client",
REPLContext.BUILTINS["client"],
"from httpx import Client",
),
)
else:
client_availability = (
f"\n{Colors.YELLOW}The HTTP client has been disabled. "
"To enable it, install httpx:\n\t"
f"pip install httpx{Colors.END}\n"
)
super().__init__(locals={**locals_available, **user_locals})
self.compile.compiler.flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT
self.loop = new_event_loop()
self._start = start
self._pause_event = threading.Event()
self._started_event = threading.Event()
self._interact_thread = threading.Thread(
target=self._console,
daemon=True,
)
self._monitor_thread = threading.Thread(
target=self._monitor,
daemon=True,
)
self._async_thread = threading.Thread(
target=self.loop.run_forever,
daemon=True,
)
self.app = app
self.resume()
self.exit_message = "Closing the REPL."
self.banner_message = "\n".join(
[
f"\n{Colors.BOLD}Welcome to the Sanic interactive console{Colors.END}", # noqa: E501
client_availability,
"The following objects are available for your convenience:", # noqa: E501
*variable_descriptions,
]
+ (
[
"\nREPL Context:",
*user_locals_descriptions,
]
if user_locals_descriptions
else []
)
+ [
"\nThe async/await keywords are available for use here.", # noqa: E501
f"To exit, press {Colors.BOLD}CTRL+C{Colors.END}, "
f"{Colors.BOLD}CTRL+D{Colors.END}, or type {Colors.BOLD}exit(){Colors.END}.\n", # noqa: E501
]
)
def pause(self):
if self.is_paused():
return
self._pause_event.clear()
def resume(self):
self._pause_event.set()
def runsource(self, source, filename="<input>", symbol="single"):
if source.strip() == "exit()":
self._shutdown()
return False
if self.is_paused():
print("Console is paused. Please wait for it to be resumed.")
return False
return super().runsource(source, filename, symbol)
def runcode(self, code):
future = concurrent.futures.Future()
async def callback():
func = FunctionType(code, self.locals)
try:
result = func()
if iscoroutine(result):
result = await result
except BaseException:
traceback.print_exc()
result = False
future.set_result(result)
self.loop.call_soon_threadsafe(self.loop.create_task, callback())
return future.result()
def is_paused(self):
return not self._pause_event.is_set()
def _console(self):
self._started_event.set()
self.interact(banner=self.banner_message, exitmsg=self.exit_message)
self._shutdown()
def _setup_terminal(self):
assert termios is not None
with suppress(termios.error, AttributeError):
fd = sys.stdin.fileno()
old_attrs = termios.tcgetattr(fd)
atexit.register(
termios.tcsetattr, fd, termios.TCSADRAIN, old_attrs
)
def _monitor(self):
if isinstance(self._start, Default):
if TERMIOS_AVAILABLE and sys.stdin.isatty():
self._setup_terminal()
enter = f"{Colors.BOLD + Colors.SANIC}ENTER{Colors.END}"
start = input(f"\nPress {enter} at anytime to start the REPL.\n\n")
if start:
return
try:
while True:
if not self._started_event.is_set():
self.app.manager.wait_for_ack()
self._interact_thread.start()
elif self.app.manager._all_workers_ack() and self.is_paused():
self.resume()
print(sys.ps1, end="", flush=True)
elif (
not self.app.manager._all_workers_ack()
and not self.is_paused()
):
self.pause()
time.sleep(0.1)
except (ConnectionResetError, BrokenPipeError):
pass
def _shutdown(self):
self.app.manager.monitor_publisher.send("__TERMINATE__")
def run(self):
self._monitor_thread.start()
self._async_thread.start()
@@ -0,0 +1,164 @@
from __future__ import annotations
import os
import signal
import sys
from argparse import ArgumentParser
from contextlib import suppress
from pathlib import Path
from sanic.worker.daemon import Daemon
def _add_target_args(parser: ArgumentParser) -> None:
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--pid",
type=int,
metavar="PID",
help="Process ID of the daemon",
)
group.add_argument(
"--pidfile",
type=str,
metavar="PATH",
help="Path to PID file of the daemon",
)
def make_kill_parser(parser: ArgumentParser) -> None:
"""Kill command always sends SIGKILL."""
_add_target_args(parser)
def make_status_parser(parser: ArgumentParser) -> None:
_add_target_args(parser)
def make_restart_parser(parser: ArgumentParser) -> None:
_add_target_args(parser)
def resolve_target(
pid: int | None, pidfile: str | None
) -> tuple[int, Path | None]:
"""
Resolve a PID from either a direct PID or a pidfile path.
Returns:
Tuple of (pid, pidfile_path or None)
Raises:
SystemExit: If pidfile not found or invalid
"""
if pid:
return pid, None
pidfile_path = Path(pidfile) # type: ignore[arg-type]
if not pidfile_path.exists():
print(f"PID file not found: {pidfile_path}", file=sys.stderr)
sys.exit(1)
resolved_pid = Daemon.read_pidfile(pidfile_path)
if resolved_pid is None:
print(f"Invalid PID file: {pidfile_path}", file=sys.stderr)
sys.exit(1)
return resolved_pid, pidfile_path
def _terminate_process(
pid: int, sig: signal.Signals, pidfile: Path | None = None
) -> None:
"""Send a signal to terminate a process and clean up pidfile."""
sig_name = sig.name
try:
os.kill(pid, sig)
print(f"Sent {sig_name} to process {pid}")
except ProcessLookupError:
print(f"Process {pid} not found", file=sys.stderr)
sys.exit(1)
except PermissionError:
print(
f"Permission denied to signal process {pid}. "
"Are you running as the correct user?",
file=sys.stderr,
)
sys.exit(1)
except OSError as e:
print(f"Failed to signal process {pid}: {e}", file=sys.stderr)
sys.exit(1)
if pidfile:
if pidfile.exists():
try:
pidfile.unlink()
print(f"Removed PID file: {pidfile}")
except OSError as e:
print(
f"Warning: Could not remove PID file {pidfile}: {e}",
file=sys.stderr,
)
lockfile = pidfile.with_suffix(".lock")
if lockfile.exists():
try:
lockfile.unlink()
except OSError:
pass
def kill_daemon(pid: int, pidfile: Path | None = None) -> None:
"""Force kill a daemon process with SIGKILL."""
_terminate_process(pid, signal.SIGKILL, pidfile)
def stop_daemon(
pid: int, pidfile: Path | None = None, force: bool = False
) -> None:
"""Stop a daemon process gracefully (SIGTERM) or forcefully (SIGKILL)."""
sig = signal.SIGKILL if force else signal.SIGTERM
_terminate_process(pid, sig, pidfile)
def status_daemon(pid: int, pidfile: Path | None = None) -> bool:
"""
Check if a daemon process is running.
Args:
pid: Process ID to check
pidfile: Optional pidfile path to clean up if stale
Returns:
True if running, False otherwise (exits with code 1 if not)
"""
try:
os.kill(pid, 0)
running = True
except ProcessLookupError:
running = False
except PermissionError:
running = True
if running:
print(f"Process {pid} is running")
return True
print(f"Process {pid} is NOT running")
if pidfile and pidfile.exists():
with suppress(OSError):
pidfile.unlink()
print(f"Removed stale PID file: {pidfile}")
sys.exit(1)
def restart_daemon(pid: int) -> None:
"""
Restart a daemon process.
Args:
pid: Process ID to restart (unused, for future use)
"""
print("Restart is not yet implemented. Coming in a future release.")
sys.exit(0)
@@ -0,0 +1,100 @@
import shutil
from argparse import ArgumentParser
from asyncio import run
from inspect import signature
from typing import Callable
from sanic import Sanic
from sanic.application.logo import get_logo
from sanic.cli.base import (
SanicArgumentParser,
SanicHelpFormatter,
)
def make_executor_parser(parser: ArgumentParser) -> None:
parser.add_argument(
"command",
help="Command to execute",
)
class ExecutorSubParser(ArgumentParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.description:
self.description = ""
self.description = get_logo(True) + self.description
class Executor:
def __init__(self, app: Sanic, kwargs: dict) -> None:
self.app = app
self.kwargs = kwargs
self.commands = self._make_commands()
self.parser = self._make_parser()
def run(self, command: str, args: list[str]) -> None:
if command == "exec":
args = ["--help"]
parsed_args = self.parser.parse_args(args)
if command not in self.commands:
raise ValueError(f"Unknown command: {command}")
parsed_kwargs = vars(parsed_args)
parsed_kwargs.pop("command")
run(self.commands[command](**parsed_kwargs))
def _make_commands(self) -> dict[str, Callable]:
commands = {c.name: c.func for c in self.app._future_commands}
return commands
def _make_parser(self) -> SanicArgumentParser:
width = shutil.get_terminal_size().columns
parser = SanicArgumentParser(
prog="sanic",
description=get_logo(True),
formatter_class=lambda prog: SanicHelpFormatter(
prog,
max_help_position=36 if width > 96 else 24,
indent_increment=4,
width=None,
),
)
subparsers = parser.add_subparsers(
dest="command",
title=" Commands",
parser_class=ExecutorSubParser,
)
for command in self.app._future_commands:
sub = subparsers.add_parser(
command.name,
help=command.func.__doc__ or f"Execute {command.name}",
formatter_class=SanicHelpFormatter,
)
self._add_arguments(sub, command.func)
return parser
def _add_arguments(self, parser: ArgumentParser, func: Callable) -> None:
sig = signature(func)
for param in sig.parameters.values():
kwargs = {}
if param.default is not param.empty:
kwargs["default"] = param.default
# In Python 3.14+, argparse validates help strings and rejects
# non-string types. Convert annotations to string representation.
help_text = None
if param.annotation is not param.empty:
if isinstance(param.annotation, str):
help_text = param.annotation
else:
help_text = getattr(
param.annotation, "__name__", str(param.annotation)
)
parser.add_argument(
f"--{param.name}",
help=help_text,
**kwargs,
)
@@ -0,0 +1,105 @@
from argparse import ArgumentParser
from sanic.application.logo import get_logo
from sanic.cli.base import SanicHelpFormatter, SanicSubParsersAction
def _add_shared(parser: ArgumentParser) -> None:
parser.add_argument(
"--host",
"-H",
default="localhost",
help="Inspector host address [default 127.0.0.1]",
)
parser.add_argument(
"--port",
"-p",
default=6457,
type=int,
help="Inspector port [default 6457]",
)
parser.add_argument(
"--secure",
"-s",
action="store_true",
help="Whether to access the Inspector via TLS encryption",
)
parser.add_argument("--api-key", "-k", help="Inspector authentication key")
parser.add_argument(
"--raw",
action="store_true",
help="Whether to output the raw response information",
)
class InspectorSubParser(ArgumentParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
_add_shared(self)
if not self.description:
self.description = ""
self.description = get_logo(True) + self.description
def make_inspector_parser(parser: ArgumentParser) -> None:
_add_shared(parser)
subparsers = parser.add_subparsers(
action=SanicSubParsersAction,
dest="action",
description=(
"Run one or none of the below subcommands. Using inspect without "
"a subcommand will fetch general information about the state "
"of the application instance.\n\n"
"Or, you can optionally follow inspect with a subcommand. "
"If you have created a custom "
"Inspector instance, then you can run custom commands. See "
"https://sanic.dev/en/guide/deployment/inspector.html "
"for more details."
),
title=" Subcommands",
parser_class=InspectorSubParser,
)
reloader = subparsers.add_parser(
"reload",
help="Trigger a reload of the server workers",
formatter_class=SanicHelpFormatter,
)
reloader.add_argument(
"--zero-downtime",
action="store_true",
help=(
"Whether to wait for the new process to be online before "
"terminating the old"
),
)
subparsers.add_parser(
"shutdown",
help="Shutdown the application and all processes",
formatter_class=SanicHelpFormatter,
)
scale = subparsers.add_parser(
"scale",
help="Scale the number of workers",
formatter_class=SanicHelpFormatter,
)
scale.add_argument(
"replicas",
type=int,
help="Number of workers requested",
)
custom = subparsers.add_parser(
"<custom>",
help="Run a custom command",
description=(
"keyword arguments:\n When running a custom command, you can "
"add keyword arguments by appending them to your command\n\n"
"\tsanic inspect foo --one=1 --two=2"
),
formatter_class=SanicHelpFormatter,
)
custom.add_argument(
"positional",
nargs="*",
help="Add one or more non-keyword args to your custom command",
)
@@ -0,0 +1,119 @@
from __future__ import annotations
import sys
from http.client import RemoteDisconnected
from textwrap import indent
from typing import Any
from urllib.error import URLError
from urllib.request import Request as URequest
from urllib.request import urlopen
from sanic.application.logo import get_logo
from sanic.application.motd import MOTDTTY
from sanic.log import Colors
try: # no cov
from ujson import dumps, loads
except ModuleNotFoundError: # no cov
from json import dumps, loads # type: ignore
class InspectorClient:
def __init__(
self,
host: str,
port: int,
secure: bool,
raw: bool,
api_key: str | None,
) -> None:
self.scheme = "https" if secure else "http"
self.host = host
self.port = port
self.raw = raw
self.api_key = api_key
for scheme in ("http", "https"):
full = f"{scheme}://"
if self.host.startswith(full):
self.scheme = scheme
self.host = self.host[len(full) :] # noqa E203
def do(self, action: str, **kwargs: Any) -> None:
if action == "info":
self.info()
return
result = self.request(action, **kwargs).get("result")
if result:
out = (
dumps(result)
if isinstance(result, (list, dict))
else str(result)
)
sys.stdout.write(out + "\n")
def info(self) -> None:
out = sys.stdout.write
response = self.request("", "GET")
if self.raw or not response:
return
data = response["result"]
display = data.pop("info")
extra = display.pop("extra", {})
display["packages"] = ", ".join(display["packages"])
MOTDTTY(get_logo(), self.base_url, display, extra).display(
version=False,
action="Inspecting",
out=out,
)
for name, info in data["workers"].items():
info = "\n".join(
f"\t{key}: {Colors.BLUE}{value}{Colors.END}"
for key, value in info.items()
)
out(
"\n"
+ indent(
"\n".join(
[
f"{Colors.BOLD}{Colors.SANIC}{name}{Colors.END}",
info,
]
),
" ",
)
+ "\n"
)
def request(self, action: str, method: str = "POST", **kwargs: Any) -> Any:
url = f"{self.base_url}/{action}"
params: dict[str, Any] = {"method": method, "headers": {}}
if kwargs:
params["data"] = dumps(kwargs).encode()
params["headers"]["content-type"] = "application/json"
if self.api_key:
params["headers"]["authorization"] = f"Bearer {self.api_key}"
request = URequest(url, **params)
try:
with urlopen(request) as response: # nosec B310
raw = response.read()
loaded = loads(raw)
if self.raw:
sys.stdout.write(dumps(loaded.get("result")) + "\n")
return {}
return loaded
except (URLError, RemoteDisconnected) as e:
sys.stderr.write(
f"{Colors.RED}Could not connect to inspector at: "
f"{Colors.YELLOW}{self.base_url}{Colors.END}\n"
"Either the application is not running, or it did not start "
f"an inspector instance.\n{e}\n"
)
sys.exit(1)
@property
def base_url(self):
return f"{self.scheme}://{self.host}:{self.port}"
@@ -0,0 +1,196 @@
import asyncio
import os
import platform
import signal
import sys
from collections.abc import Awaitable
from contextlib import contextmanager
from enum import Enum
from typing import Literal
from multidict import CIMultiDict # type: ignore
from sanic.helpers import Default
from sanic.log import error_logger
StartMethod = (
Default | Literal["fork"] | Literal["forkserver"] | Literal["spawn"]
)
OS_IS_WINDOWS = os.name == "nt"
PYPY_IMPLEMENTATION = platform.python_implementation() == "PyPy"
UVLOOP_INSTALLED = False
PYTHON_314_OR_LATER = sys.version_info >= (3, 14)
try:
import uvloop # type: ignore # noqa
UVLOOP_INSTALLED = True
except ImportError:
pass
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
from enum import StrEnum # type: ignore # noqa
class UpperStrEnum(StrEnum):
"""Base class for string enums that are case insensitive."""
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
@contextmanager
def use_context(method: StartMethod):
from sanic import Sanic
orig = Sanic.start_method
Sanic.start_method = method
yield
Sanic.start_method = orig
def enable_windows_color_support():
import ctypes
kernel = ctypes.windll.kernel32
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7)
def pypy_os_module_patch() -> None:
"""
The PyPy os module is missing the 'readlink' function, which causes issues
withaiofiles. This workaround replaces the missing 'readlink' function
with 'os.path.realpath', which serves the same purpose.
"""
if hasattr(os, "readlink"):
error_logger.debug(
"PyPy: Skipping patching of the os module as it appears the "
"'readlink' function has been added."
)
return
module = sys.modules["os"]
module.readlink = os.path.realpath # type: ignore
def pypy_windows_set_console_cp_patch() -> None:
"""
A patch function for PyPy on Windows that sets the console code page to
UTF-8 encodingto allow for proper handling of non-ASCII characters. This
function uses ctypes to call the Windows API functions SetConsoleCP and
SetConsoleOutputCP to set the code page.
"""
from ctypes import windll # type: ignore
code: int = windll.kernel32.GetConsoleOutputCP()
if code != 65001:
windll.kernel32.SetConsoleCP(65001)
windll.kernel32.SetConsoleOutputCP(65001)
class Header(CIMultiDict):
"""Container used for both request and response headers.
It is a subclass of [CIMultiDict](https://multidict.readthedocs.io/en/stable/multidict.html#cimultidictproxy)
It allows for multiple values for a single key in keeping with the HTTP
spec. Also, all keys are *case in-sensitive*.
Please checkout [the MultiDict documentation](https://multidict.readthedocs.io/en/stable/multidict.html#multidict)
for more details about how to use the object. In general, it should work
very similar to a regular dictionary.
""" # noqa: E501
def __getattr__(self, key: str) -> str:
if key.startswith("_"):
return self.__getattribute__(key)
key = key.rstrip("_").replace("_", "-")
return ",".join(self.getall(key, []))
def get_all(self, key: str):
"""Convenience method mapped to ``getall()``."""
return self.getall(key, [])
use_trio = sys.argv[0].endswith("hypercorn") and "trio" in sys.argv
if use_trio: # pragma: no cover
import trio # type: ignore
def stat_async(path) -> Awaitable[os.stat_result]:
return trio.Path(path).stat()
open_async = trio.open_file
CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled])
else:
if PYPY_IMPLEMENTATION:
pypy_os_module_patch()
if OS_IS_WINDOWS:
pypy_windows_set_console_cp_patch()
from aiofiles import open as aio_open # type: ignore
from aiofiles.os import stat as stat_async # type: ignore # noqa: F401
async def open_async(file, mode="r", **kwargs):
return aio_open(file, mode, **kwargs)
CancelledErrors = tuple([asyncio.CancelledError])
def ctrlc_workaround_for_windows(app):
async def stay_active(app):
"""Asyncio wakeups to allow receiving SIGINT in Python"""
while not die:
# If someone else stopped the app, just exit
if app.state.is_stopping:
return
# Windows Python blocks signal handlers while the event loop is
# waiting for I/O. Frequent wakeups keep interrupts flowing.
await asyncio.sleep(0.1)
# Can't be called from signal handler, so call it from here
app.stop()
def ctrlc_handler(sig, frame):
nonlocal die
if die:
raise KeyboardInterrupt("Non-graceful Ctrl+C")
die = True
die = False
signal.signal(signal.SIGINT, ctrlc_handler)
app.add_task(stay_active)
def clear_function_annotate(*funcs):
"""Clear __annotate__ on functions for Python 3.14+ pickle compatibility.
In Python 3.14, PEP 649 adds __annotate__ to functions with annotations.
When methods are used in functools.partial and pickled, the __annotate__
function can cause PicklingError because pickle cannot locate it by name.
This function sets __annotate__ to None on the given functions to avoid
pickle issues.
"""
if PYTHON_314_OR_LATER:
for func in funcs:
if hasattr(func, "__annotate__") and func.__annotate__ is not None:
func.__annotate__ = None
@@ -0,0 +1,495 @@
from __future__ import annotations
from abc import ABC, ABCMeta, abstractmethod
from collections.abc import Sequence
from inspect import getmembers, isclass, isdatadescriptor
from os import environ
from pathlib import Path
from typing import Any, Callable, Literal
from warnings import filterwarnings
from sanic.constants import LocalCertCreator
from sanic.errorpages import DEFAULT_FORMAT, check_error_format
from sanic.helpers import Default, _default
from sanic.http import Http
from sanic.log import error_logger
from sanic.utils import load_module_from_file_location, str_to_bool
FilterWarningType = (
Literal["default"]
| Literal["error"]
| Literal["ignore"]
| Literal["always"]
| Literal["module"]
| Literal["once"]
)
SANIC_PREFIX = "SANIC_"
DEFAULT_CONFIG = {
"_FALLBACK_ERROR_FORMAT": _default,
"ACCESS_LOG": False,
"AUTO_EXTEND": True,
"AUTO_RELOAD": False,
"EVENT_AUTOREGISTER": False,
"DEPRECATION_FILTER": "once",
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FORWARDED_SECRET": None,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0,
"GRACEFUL_TCP_CLOSE_TIMEOUT": 5.0,
"INSPECTOR": False,
"INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default,
"INSPECTOR_API_KEY": "",
"KEEP_ALIVE_TIMEOUT": 120,
"KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
"LOCAL_TLS_KEY": _default,
"LOCAL_TLS_CERT": _default,
"LOCALHOST": "localhost",
"LOG_EXTRA": _default,
"MOTD": True,
"MOTD_DISPLAY": {},
"NO_COLOR": False,
"NOISY_EXCEPTIONS": False,
"PROXIES_COUNT": None,
"REAL_IP_HEADER": None,
"REQUEST_BUFFER_SIZE": 65536,
"REQUEST_MAX_HEADER_SIZE": 8192, # Cannot exceed 16384
"REQUEST_ID_HEADER": "X-Request-ID",
"REQUEST_MAX_SIZE": 100_000_000,
"REQUEST_TIMEOUT": 60,
"RESPONSE_TIMEOUT": 60,
"TLS_CERT_PASSWORD": "",
"TOUCHUP": _default,
"USE_UVLOOP": _default,
"WEBSOCKET_MAX_SIZE": 2**20, # 1 MiB
"WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20,
}
class DescriptorMeta(ABCMeta):
"""Metaclass for Config."""
def __init__(cls, *_):
cls.__setters__ = {name for name, _ in getmembers(cls, cls._is_setter)}
@staticmethod
def _is_setter(member: object):
return isdatadescriptor(member) and hasattr(member, "setter")
class DetailedConverter(ABC):
"""Base class for detailed converters that need additional context.
DetailedConverter provides access to the full environment variable key,
the raw value, and the current config defaults. This allows for more
sophisticated conversion logic that can take into account the variable
name pattern, perform validation, or use default values for fallback.
Examples:
```python
# Example of a converter that casts values to the type of the default
class DefaultsCastConverter(DetailedConverter):
def __call__(self, full_key: str, config_key: str, value: str,
defaults: dict) -> Any:
try:
if config_key in defaults:
return type(defaults[config_key])(value)
except (ValueError, TypeError):
raise ValueError
```
"""
@abstractmethod
def __call__(
self, full_key: str, config_key: str, value: str, defaults: dict
) -> Any:
"""Convert an environment variable to a Python value.
Args:
full_key: The full environment variable name (with prefix)
(e.g., "SANIC_DATABASE_URL")
config_key: The environment variable name (without prefix)
(e.g., "DATABASE_URL")
value: The raw string value from the environment
defaults: The current default configuration values
Returns:
The converted Python value
Raises:
ValueError: If the value cannot be converted by this converter
"""
class Config(dict, metaclass=DescriptorMeta):
"""Configuration object for Sanic.
You can use this object to both: (1) configure how Sanic will operate, and
(2) manage your application's custom configuration values.
"""
ACCESS_LOG: bool
AUTO_EXTEND: bool
AUTO_RELOAD: bool
EVENT_AUTOREGISTER: bool
DEPRECATION_FILTER: FilterWarningType
FORWARDED_FOR_HEADER: str
FORWARDED_SECRET: str | None
GRACEFUL_SHUTDOWN_TIMEOUT: float
GRACEFUL_TCP_CLOSE_TIMEOUT: float
INSPECTOR: bool
INSPECTOR_HOST: str
INSPECTOR_PORT: int
INSPECTOR_TLS_KEY: Path | str | Default
INSPECTOR_TLS_CERT: Path | str | Default
INSPECTOR_API_KEY: str
KEEP_ALIVE_TIMEOUT: int
KEEP_ALIVE: bool
LOCAL_CERT_CREATOR: str | LocalCertCreator
LOCAL_TLS_KEY: Path | str | Default
LOCAL_TLS_CERT: Path | str | Default
LOCALHOST: str
LOG_EXTRA: Default | bool
MOTD: bool
MOTD_DISPLAY: dict[str, str]
NO_COLOR: bool
NOISY_EXCEPTIONS: bool
PROXIES_COUNT: int | None
REAL_IP_HEADER: str | None
REQUEST_BUFFER_SIZE: int
REQUEST_MAX_HEADER_SIZE: int
REQUEST_ID_HEADER: str
REQUEST_MAX_SIZE: int
REQUEST_TIMEOUT: int
RESPONSE_TIMEOUT: int
SERVER_NAME: str
TLS_CERT_PASSWORD: str
TOUCHUP: Default | bool
USE_UVLOOP: Default | bool
WEBSOCKET_MAX_SIZE: int
WEBSOCKET_PING_INTERVAL: int
WEBSOCKET_PING_TIMEOUT: int
def __init__(
self,
defaults: dict[str, str | bool | int | float | None] | None = None,
env_prefix: str | None = SANIC_PREFIX,
keep_alive: bool | None = None,
*,
converters: Sequence[Callable[[str], Any]] | None = None,
):
defaults = defaults or {}
self.defaults = {**DEFAULT_CONFIG, **defaults}
super().__init__(self.defaults)
self._configure_warnings()
self._converters = [str, str_to_bool, float, int]
if converters:
for converter in converters:
self.register_type(converter)
if keep_alive is not None:
self.KEEP_ALIVE = keep_alive
if env_prefix != SANIC_PREFIX:
if env_prefix:
self.load_environment_vars(env_prefix)
else:
self.load_environment_vars(SANIC_PREFIX)
self._configure_header_size()
self._check_error_format()
self._init = True
def __getattr__(self, attr: Any):
try:
return self[attr]
except KeyError as ke:
raise AttributeError(f"Config has no '{ke.args[0]}'")
def __setattr__(self, attr: str, value: Any) -> None:
self.update({attr: value})
def __setitem__(self, attr: str, value: Any) -> None:
self.update({attr: value})
def update(self, *other: Any, **kwargs: Any) -> None:
"""Update the config with new values.
This method will update the config with the values from the provided
`other` objects, and then update the config with the provided
`kwargs`. The `other` objects can be any object that can be converted
to a dictionary, such as a `dict`, `Config` object, or `str` path to a
Python file. The `kwargs` must be a dictionary of key-value pairs.
.. note::
Only upper case settings are considered
Args:
*other: Any number of objects that can be converted to a
dictionary.
**kwargs: Any number of key-value pairs.
Raises:
AttributeError: If a key is not in the config.
Examples:
```python
config.update(
{"A": 1, "B": 2},
{"C": 3, "D": 4},
E=5,
F=6,
)
```
"""
kwargs.update({k: v for item in other for k, v in dict(item).items()})
setters: dict[str, Any] = {
k: kwargs.pop(k)
for k in {**kwargs}.keys()
if k in self.__class__.__setters__
}
for key, value in setters.items():
try:
super().__setattr__(key, value)
except AttributeError:
...
super().update(**kwargs)
for attr, value in {**setters, **kwargs}.items():
self._post_set(attr, value)
def _post_set(self, attr, value) -> None:
if self.get("_init"):
if attr in (
"REQUEST_MAX_HEADER_SIZE",
"REQUEST_BUFFER_SIZE",
"REQUEST_MAX_SIZE",
):
self._configure_header_size()
if attr == "LOCAL_CERT_CREATOR" and not isinstance(
self.LOCAL_CERT_CREATOR, LocalCertCreator
):
self.LOCAL_CERT_CREATOR = LocalCertCreator[
self.LOCAL_CERT_CREATOR.upper()
]
elif attr == "DEPRECATION_FILTER":
self._configure_warnings()
@property
def FALLBACK_ERROR_FORMAT(self) -> str:
if isinstance(self._FALLBACK_ERROR_FORMAT, Default):
return DEFAULT_FORMAT
return self._FALLBACK_ERROR_FORMAT
@FALLBACK_ERROR_FORMAT.setter
def FALLBACK_ERROR_FORMAT(self, value):
self._check_error_format(value)
if (
not isinstance(self._FALLBACK_ERROR_FORMAT, Default)
and value != self._FALLBACK_ERROR_FORMAT
):
error_logger.warning(
"Setting config.FALLBACK_ERROR_FORMAT on an already "
"configured value may have unintended consequences."
)
self._FALLBACK_ERROR_FORMAT = value
def _configure_header_size(self):
Http.set_header_max_size(
self.REQUEST_MAX_HEADER_SIZE,
self.REQUEST_BUFFER_SIZE - 4096,
self.REQUEST_MAX_SIZE,
)
def _configure_warnings(self):
filterwarnings(
self.DEPRECATION_FILTER,
category=DeprecationWarning,
module=r"sanic.*",
)
def _check_error_format(self, format: str | None = None):
check_error_format(format or self.FALLBACK_ERROR_FORMAT)
def load_environment_vars(self, prefix=SANIC_PREFIX):
"""Load environment variables into the config.
Looks for prefixed environment variables and applies them to the
configuration if present. This is called automatically when Sanic
starts up to load environment variables into config. Environment
variables should start with the defined prefix and should only
contain uppercase letters.
It will automatically hydrate the following types:
- ``int``
- ``float``
- ``bool``
Anything else will be imported as a ``str``. If you would like to add
additional types to this list, you can use
:meth:`sanic.config.Config.register_type`. Just make sure that they
are registered before you instantiate your application.
You likely won't need to call this method directly.
See [Configuration](/en/guide/deployment/configuration) for more details.
Args:
prefix (str): The prefix to use when looking for environment
variables. Defaults to `SANIC_`.
Examples:
```python
# Environment variables
# SANIC_SERVER_NAME=example.com
# SANIC_SERVER_PORT=9999
# SANIC_SERVER_AUTORELOAD=true
# Python
app.config.load_environment_vars()
```
""" # noqa: E501
for key, value in environ.items():
if not key.startswith(prefix) or not key.isupper():
continue
_, config_key = key.split(prefix, 1)
for converter in reversed(self._converters):
try:
if isinstance(converter, DetailedConverter):
self[config_key] = converter(
key, config_key, value, self.defaults
)
else:
self[config_key] = converter(value)
break
except ValueError:
pass
def update_config(self, config: bytes | str | dict[str, Any] | Any):
"""Update app.config.
.. note::
Only upper case settings are considered
See [Configuration](/en/guide/deployment/configuration) for more details.
Args:
config (Union[bytes, str, dict, Any]): Path to py file holding
settings, dict holding settings, or any object holding
settings.
Examples:
You can upload app config by providing path to py file
holding settings.
```python
# /some/py/file
A = 1
B = 2
```
```python
config.update_config("${some}/py/file")
```
Yes you can put environment variable here, but they must be provided
in format: ``${some_env_var}``, and mark that ``$some_env_var`` is
treated as plain string.
You can upload app config by providing dict holding settings.
```python
d = {"A": 1, "B": 2}
config.update_config(d)
```
You can upload app config by providing any object holding settings,
but in such case config.__dict__ will be used as dict holding settings.
```python
class C:
A = 1
B = 2
config.update_config(C)
```
""" # noqa: E501
if isinstance(config, (bytes, str, Path)):
config = load_module_from_file_location(location=config)
if not isinstance(config, dict):
cfg = {}
if not isclass(config):
cfg.update(
{
key: getattr(config, key)
for key in config.__class__.__dict__.keys()
}
)
config = dict(config.__dict__)
config.update(cfg)
config = dict(filter(lambda i: i[0].isupper(), config.items()))
self.update(config)
load = update_config
def register_type(self, converter: Callable[[str], Any]) -> None:
"""Register a custom type converter.
Allows for adding custom function to cast from a string value to any
other type. The function should raise ValueError if it is not the
correct type.
Args:
converter (Callable[[str], Any]): A function that takes a string
and returns a value of any type, or a DetailedConverter instance.
Examples:
```python
def my_converter(value: str) -> Any:
# Do something to convert the value
return value
config.register_type(my_converter)
# Or use a DetailedConverter for more context
# Example of a converter that casts values to
# the type of the default
class DefaultsCastConverter(DetailedConverter):
def __call__(self, full_key: str, config_key: str, value: str,
defaults: dict) -> Any:
try:
if config_key in defaults:
return type(defaults[config_key])(value)
except (ValueError, TypeError):
raise ValueError
config.register_type(DefaultsCastConverter())
```
"""
if converter in self._converters:
error_logger.warning(
f"Configuration value converter '{converter.__name__}' has "
"already been registered"
)
return
self._converters.append(converter)
@@ -0,0 +1,38 @@
from enum import auto
from sanic.compat import UpperStrEnum
class HTTPMethod(UpperStrEnum):
"""HTTP methods that are commonly used."""
GET = auto()
POST = auto()
PUT = auto()
HEAD = auto()
OPTIONS = auto()
PATCH = auto()
DELETE = auto()
class LocalCertCreator(UpperStrEnum):
"""Local certificate creator."""
AUTO = auto()
TRUSTME = auto()
MKCERT = auto()
HTTP_METHODS = tuple(HTTPMethod.__members__.values())
SAFE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.OPTIONS)
IDEMPOTENT_HTTP_METHODS = (
HTTPMethod.GET,
HTTPMethod.HEAD,
HTTPMethod.PUT,
HTTPMethod.DELETE,
HTTPMethod.OPTIONS,
)
CACHEABLE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD)
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"
DEFAULT_LOCAL_TLS_KEY = "key.pem"
DEFAULT_LOCAL_TLS_CERT = "cert.pem"
@@ -0,0 +1,4 @@
from .response import Cookie, CookieJar
__all__ = ("Cookie", "CookieJar")
@@ -0,0 +1,159 @@
import re
from typing import Any
from sanic.cookies.response import Cookie
from sanic.request.parameters import RequestParameters
COOKIE_NAME_RESERVED_CHARS = re.compile(
'[\x00-\x1f\x7f-\xff()<>@,;:\\\\"/[\\]?={} \x09]'
)
OCTAL_PATTERN = re.compile(r"\\[0-3][0-7][0-7]")
QUOTE_PATTERN = re.compile(r"[\\].")
def _unquote(str): # no cov
if str is None or len(str) < 2:
return str
if str[0] != '"' or str[-1] != '"':
return str
str = str[1:-1]
i = 0
n = len(str)
res = []
while 0 <= i < n:
o_match = OCTAL_PATTERN.search(str, i)
q_match = QUOTE_PATTERN.search(str, i)
if not o_match and not q_match:
res.append(str[i:])
break
# else:
j = k = -1
if o_match:
j = o_match.start(0)
if q_match:
k = q_match.start(0)
if q_match and (not o_match or k < j):
res.append(str[i:k])
res.append(str[k + 1])
i = k + 2
else:
res.append(str[i:j])
res.append(chr(int(str[j + 1 : j + 4], 8))) # noqa: E203
i = j + 4
return "".join(res)
def parse_cookie(raw: str) -> dict[str, list[str]]:
"""Parses a raw cookie string into a dictionary.
The function takes a raw cookie string (usually from HTTP headers) and
returns a dictionary where each key is a cookie name and the value is a
list of values for that cookie. The function handles quoted values and
skips invalid cookie names.
Args:
raw (str): The raw cookie string to be parsed.
Returns:
Dict[str, List[str]]: A dictionary containing the cookie names as keys
and a list of values for each cookie.
Example:
```python
raw = 'name1=value1; name2="value2"; name3=value3'
cookies = parse_cookie(raw)
# cookies will be {'name1': ['value1'], 'name2': ['value2'], 'name3': ['value3']}
```
""" # noqa: E501
cookies: dict[str, list[str]] = {}
for token in raw.split(";"):
name, sep, value = token.partition("=")
name = name.strip()
value = value.strip()
# Support cookies =value or plain value with no name
# https://github.com/httpwg/http-extensions/issues/159
if not sep:
if not name:
# Empty value like ;; or a cookie header with no value
continue
name, value = "", name
if COOKIE_NAME_RESERVED_CHARS.search(name): # no cov
continue
if len(value) > 2 and value[0] == '"' and value[-1] == '"': # no cov
value = _unquote(value)
if name in cookies:
cookies[name].append(value)
else:
cookies[name] = [value]
return cookies
class CookieRequestParameters(RequestParameters):
"""A container for accessing single and multiple cookie values.
Because the HTTP standard allows for multiple cookies with the same name,
a standard dictionary cannot be used to access cookie values. This class
provides a way to access cookie values in a way that is similar to a
dictionary, but also allows for accessing multiple values for a single
cookie name when necessary.
Args:
cookies (Dict[str, List[str]]): A dictionary containing the cookie
names as keys and a list of values for each cookie.
Example:
```python
raw = 'name1=value1; name2="value2"; name3=value3'
cookies = parse_cookie(raw)
# cookies will be {'name1': ['value1'], 'name2': ['value2'], 'name3': ['value3']}
request_cookies = CookieRequestParameters(cookies)
request_cookies['name1'] # 'value1'
request_cookies.get('name1') # 'value1'
request_cookies.getlist('name1') # ['value1']
```
""" # noqa: E501
def __getitem__(self, key: str) -> str | None:
try:
value = self._get_prefixed_cookie(key)
except KeyError:
value = super().__getitem__(key)
return value
def __getattr__(self, key: str) -> str:
if key.startswith("_"):
return self.__getattribute__(key)
key = key.rstrip("_").replace("_", "-")
return str(self.get(key, ""))
def get(self, name: str, default: Any | None = None) -> Any | None:
try:
return self._get_prefixed_cookie(name)[0]
except KeyError:
return super().get(name, default)
def getlist(
self, name: str, default: list[Any] | None = None
) -> list[Any]:
try:
return self._get_prefixed_cookie(name)
except KeyError:
return super().getlist(name, default)
def _get_prefixed_cookie(self, name: str) -> Any:
getitem = super().__getitem__
try:
return getitem(f"{Cookie.HOST_PREFIX}{name}")
except KeyError:
return getitem(f"{Cookie.SECURE_PREFIX}{name}")
@@ -0,0 +1,607 @@
from __future__ import annotations
import re
import string
from datetime import datetime
from typing import TYPE_CHECKING, Literal, cast
from sanic.exceptions import ServerError
if TYPE_CHECKING:
from sanic.compat import Header
SameSite = (
Literal["Strict"]
| Literal["Lax"]
| Literal["None"]
| Literal["strict"]
| Literal["lax"]
| Literal["none"]
)
DEFAULT_MAX_AGE = 0
SAMESITE_VALUES = ("strict", "lax", "none")
LEGAL_CHARS = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:"
UNESCAPED_CHARS = LEGAL_CHARS + " ()/<=>?@[]{}"
TRANSLATOR = {ch: f"\\{ch:03o}" for ch in bytes(range(32)) + b'";\\\x7f'}
def _quote(str): # no cov
r"""Quote a string for use in a cookie header.
If the string does not need to be double-quoted, then just return the
string. Otherwise, surround the string in doublequotes and quote
(with a \) special characters.
"""
if str is None or _is_legal_key(str):
return str
else:
return f'"{str.translate(TRANSLATOR)}"'
_is_legal_key = re.compile("[%s]+" % re.escape(LEGAL_CHARS)).fullmatch
class CookieJar:
"""A container to manipulate cookies.
CookieJar dynamically writes headers as cookies are added and removed
It gets around the limitation of one header per name by using the
MultiHeader class to provide a unique key that encodes to Set-Cookie.
Args:
headers (Header): The headers object to write cookies to.
"""
HEADER_KEY = "Set-Cookie"
def __init__(self, headers: Header):
self.headers = headers
def __len__(self): # no cov
return len(self.cookies)
@property
def cookies(self) -> list[Cookie]:
"""A list of cookies in the CookieJar.
Returns:
List[Cookie]: A list of cookies in the CookieJar.
"""
return self.headers.getall(self.HEADER_KEY, [])
def get_cookie(
self,
key: str,
path: str = "/",
domain: str | None = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie | None:
"""Fetch a cookie from the CookieJar.
Args:
key (str): The key of the cookie to fetch.
path (str, optional): The path of the cookie. Defaults to `"/"`.
domain (Optional[str], optional): The domain of the cookie.
Defaults to `None`.
host_prefix (bool, optional): Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True.
Defaults to `False`.
secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key.
This requires that secure=True. Defaults to `False`.
Returns:
Optional[Cookie]: The cookie if it exists, otherwise `None`.
""" # noqa: E501
for cookie in self.cookies:
if (
cookie.key == Cookie.make_key(key, host_prefix, secure_prefix)
and cookie.path == path
and cookie.domain == domain
):
return cookie
return None
def has_cookie(
self,
key: str,
path: str = "/",
domain: str | None = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> bool:
"""Check if a cookie exists in the CookieJar.
Args:
key (str): The key of the cookie to check.
path (str, optional): The path of the cookie. Defaults to `"/"`.
domain (Optional[str], optional): The domain of the cookie.
Defaults to `None`.
host_prefix (bool, optional): Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True.
Defaults to `False`.
secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key.
This requires that secure=True. Defaults to `False`.
Returns:
bool: Whether the cookie exists.
""" # noqa: E501
for cookie in self.cookies:
if (
cookie.key == Cookie.make_key(key, host_prefix, secure_prefix)
and cookie.path == path
and cookie.domain == domain
):
return True
return False
def add_cookie(
self,
key: str,
value: str,
*,
path: str = "/",
domain: str | None = None,
secure: bool = True,
max_age: int | None = None,
expires: datetime | None = None,
httponly: bool = False,
samesite: SameSite | None = "Lax",
partitioned: bool = False,
comment: str | None = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie:
"""Add a cookie to the CookieJar.
Args:
key (str): Key of the cookie.
value (str): Value of the cookie.
path (str, optional): Path of the cookie. Defaults to "/".
domain (Optional[str], optional): Domain of the cookie. Defaults to None.
secure (bool, optional): Whether to set it as a secure cookie. Defaults to True.
max_age (Optional[int], optional): Max age of the cookie in seconds; if set to 0 a
browser should delete it. Defaults to None.
expires (Optional[datetime], optional): When the cookie expires; if set to None browsers
should set it as a session cookie. Defaults to None.
httponly (bool, optional): Whether to set it as HTTP only. Defaults to False.
samesite (Optional[SameSite], optional): How to set the samesite property, should be
strict, lax, or none (case insensitive). Defaults to "Lax".
partitioned (bool, optional): Whether to set it as partitioned. Defaults to False.
comment (Optional[str], optional): A cookie comment. Defaults to None.
host_prefix (bool, optional): Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True. Defaults to False.
secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key.
This requires that secure=True. Defaults to False.
Returns:
Cookie: The instance of the created cookie.
Raises:
ServerError: If host_prefix is set without secure=True.
ServerError: If host_prefix is set without path="/" and domain=None.
ServerError: If host_prefix is set with domain.
ServerError: If secure_prefix is set without secure=True.
ServerError: If partitioned is set without host_prefix=True.
Examples:
Basic usage
```python
cookie = add_cookie('name', 'value')
```
Adding a cookie with a custom path and domain
```python
cookie = add_cookie('name', 'value', path='/custom', domain='example.com')
```
Adding a secure, HTTP-only cookie with a comment
```python
cookie = add_cookie('name', 'value', secure=True, httponly=True, comment='My Cookie')
```
Adding a cookie with a max age of 60 seconds
```python
cookie = add_cookie('name', 'value', max_age=60)
```
""" # noqa: E501
cookie = Cookie(
key,
value,
path=path,
expires=expires,
comment=comment,
domain=domain,
max_age=max_age,
secure=secure,
httponly=httponly,
samesite=samesite,
partitioned=partitioned,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
self.headers.add(self.HEADER_KEY, cookie)
return cookie
def delete_cookie(
self,
key: str,
*,
path: str = "/",
domain: str | None = None,
secure: bool = True,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> None:
"""
Delete a cookie
This will effectively set it as Max-Age: 0, which a browser should
interpret it to mean: "delete the cookie".
Since it is a browser/client implementation, your results may vary
depending upon which client is being used.
:param key: The key to be deleted
:type key: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param secure: Whether to delete a secure cookie. Defaults to True.
:param secure: bool
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
"""
if host_prefix and not (secure and path == "/" and domain is None):
raise ServerError(
"Cannot set host_prefix on a cookie without "
"path='/', domain=None, and secure=True"
)
if secure_prefix and not secure:
raise ServerError(
"Cannot set secure_prefix on a cookie without secure=True"
)
cookies: list[Cookie] = self.headers.popall(self.HEADER_KEY, [])
existing_cookie = None
for cookie in cookies:
if (
cookie.key != Cookie.make_key(key, host_prefix, secure_prefix)
or cookie.path != path
or cookie.domain != domain
):
self.headers.add(self.HEADER_KEY, cookie)
elif existing_cookie is None:
existing_cookie = cookie
if existing_cookie is not None:
# Use all the same values as the cookie to be deleted
# except value="" and max_age=0
self.add_cookie(
key=key,
value="",
path=existing_cookie.path,
domain=existing_cookie.domain,
secure=existing_cookie.secure,
max_age=0,
httponly=existing_cookie.httponly,
partitioned=existing_cookie.partitioned,
samesite=existing_cookie.samesite,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
else:
self.add_cookie(
key=key,
value="",
path=path,
domain=domain,
secure=secure,
max_age=0,
samesite=None,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
class Cookie:
"""A representation of a HTTP cookie, providing an interface to manipulate cookie attributes intended for a response.
This class is a simplified representation of a cookie, similar to the Morsel SimpleCookie in Python's standard library.
It allows the manipulation of various cookie attributes including path, domain, security settings, and others.
Several "smart defaults" are provided to make it easier to create cookies that are secure by default. These include:
- Setting the `secure` flag to `True` by default
- Setting the `samesite` flag to `Lax` by default
Args:
key (str): The key (name) of the cookie.
value (str): The value of the cookie.
path (str, optional): The path for the cookie. Defaults to "/".
domain (Optional[str], optional): The domain for the cookie.
Defaults to `None`.
secure (bool, optional): Whether the cookie is secure.
Defaults to `True`.
max_age (Optional[int], optional): The maximum age of the cookie
in seconds. Defaults to `None`.
expires (Optional[datetime], optional): The expiration date of the
cookie. Defaults to `None`.
httponly (bool, optional): HttpOnly flag for the cookie.
Defaults to `False`.
samesite (Optional[SameSite], optional): The SameSite attribute for
the cookie. Defaults to `"Lax"`.
partitioned (bool, optional): Whether the cookie is partitioned.
Defaults to `False`.
comment (Optional[str], optional): A comment for the cookie.
Defaults to `None`.
host_prefix (bool, optional): Whether to use the host prefix.
Defaults to `False`.
secure_prefix (bool, optional): Whether to use the secure prefix.
Defaults to `False`.
""" # noqa: E501
HOST_PREFIX = "__Host-"
SECURE_PREFIX = "__Secure-"
__slots__ = (
"key",
"value",
"_path",
"_comment",
"_domain",
"_secure",
"_httponly",
"_partitioned",
"_expires",
"_max_age",
"_samesite",
)
_keys = {
"path": "Path",
"comment": "Comment",
"domain": "Domain",
"max-age": "Max-Age",
"expires": "expires",
"samesite": "SameSite",
# "version": "Version",
"secure": "Secure",
"httponly": "HttpOnly",
"partitioned": "Partitioned",
}
_flags = {"secure", "httponly", "partitioned"}
def __init__(
self,
key: str,
value: str,
*,
path: str = "/",
domain: str | None = None,
secure: bool = True,
max_age: int | None = None,
expires: datetime | None = None,
httponly: bool = False,
samesite: SameSite | None = "Lax",
partitioned: bool = False,
comment: str | None = None,
host_prefix: bool = False,
secure_prefix: bool = False,
):
if key in self._keys:
raise KeyError("Cookie name is a reserved word")
if not _is_legal_key(key):
raise KeyError("Cookie key contains illegal characters")
if host_prefix:
if not secure:
raise ServerError(
"Cannot set host_prefix on a cookie without secure=True"
)
if path != "/":
raise ServerError(
"Cannot set host_prefix on a cookie unless path='/'"
)
if domain:
raise ServerError(
"Cannot set host_prefix on a cookie with a defined domain"
)
elif secure_prefix and not secure:
raise ServerError(
"Cannot set secure_prefix on a cookie without secure=True"
)
if partitioned and not host_prefix:
# This is technically possible, but it is not advisable so we will
# take a stand and say "don't shoot yourself in the foot"
raise ServerError(
"Cannot create a partitioned cookie without "
"also setting host_prefix=True"
)
self.key = self.make_key(key, host_prefix, secure_prefix)
self.value = value
self._path = path
self._comment = comment
self._domain = domain
self._secure = secure
self._httponly = httponly
self._partitioned = partitioned
self._expires: datetime | None = None
self._max_age: int | None = None
self._samesite: SameSite | None = None
if expires is not None:
self.expires = expires
if max_age is not None:
self.max_age = max_age
if samesite is not None:
self.samesite = samesite
def __str__(self):
"""Format as a Set-Cookie header value."""
output = ["{}={}".format(self.key, _quote(self.value))]
ordered_keys = list(self._keys.keys())
for key in sorted(
self._keys.keys(), key=lambda k: ordered_keys.index(k)
):
value = getattr(self, key.replace("-", "_"))
if value is not None and value is not False:
if key == "max-age":
try:
output.append("%s=%d" % (self._keys[key], value))
except TypeError:
output.append("{}={}".format(self._keys[key], value))
elif key == "expires":
output.append(
"%s=%s"
% (
self._keys[key],
value.strftime("%a, %d-%b-%Y %T GMT"),
)
)
elif key in self._flags:
output.append(self._keys[key])
else:
output.append("{}={}".format(self._keys[key], value))
return "; ".join(output)
@property
def path(self) -> str: # no cov
"""The path of the cookie. Defaults to `"/"`."""
return self._path
@path.setter
def path(self, value: str) -> None: # no cov
self._path = value
@property
def expires(self) -> datetime | None: # no cov
"""The expiration date of the cookie. Defaults to `None`."""
return self._expires
@expires.setter
def expires(self, value: datetime) -> None: # no cov
if not isinstance(value, datetime):
raise TypeError("Cookie 'expires' property must be a datetime")
self._expires = value
@property
def comment(self) -> str | None: # no cov
"""A comment for the cookie. Defaults to `None`."""
return self._comment
@comment.setter
def comment(self, value: str) -> None: # no cov
self._comment = value
@property
def domain(self) -> str | None: # no cov
"""The domain of the cookie. Defaults to `None`."""
return self._domain
@domain.setter
def domain(self, value: str) -> None: # no cov
self._domain = value
@property
def max_age(self) -> int | None: # no cov
"""The maximum age of the cookie in seconds. Defaults to `None`."""
return self._max_age
@max_age.setter
def max_age(self, value: int) -> None: # no cov
if not str(value).isdigit():
raise ValueError("Cookie max-age must be an integer")
self._max_age = value
@property
def secure(self) -> bool: # no cov
"""Whether the cookie is secure. Defaults to `True`."""
return self._secure
@secure.setter
def secure(self, value: bool) -> None: # no cov
self._secure = value
@property
def httponly(self) -> bool: # no cov
"""Whether the cookie is HTTP only. Defaults to `False`."""
return self._httponly
@httponly.setter
def httponly(self, value: bool) -> None: # no cov
self._httponly = value
@property
def samesite(self) -> SameSite | None: # no cov
"""The SameSite attribute for the cookie. Defaults to `"Lax"`."""
return self._samesite
@samesite.setter
def samesite(self, value: SameSite) -> None: # no cov
if value.lower() not in SAMESITE_VALUES:
raise TypeError(
"Cookie 'samesite' property must "
f"be one of: {','.join(SAMESITE_VALUES)}"
)
self._samesite = cast(SameSite, value.title())
@property
def partitioned(self) -> bool: # no cov
"""Whether the cookie is partitioned. Defaults to `False`."""
return self._partitioned
@partitioned.setter
def partitioned(self, value: bool) -> None: # no cov
self._partitioned = value
@classmethod
def make_key(
cls, key: str, host_prefix: bool = False, secure_prefix: bool = False
) -> str:
"""Create a cookie key with the appropriate prefix.
Cookies can have one ow two prefixes. The first is `__Host-` which
requires that the cookie be set with `path="/", domain=None, and
secure=True`. The second is `__Secure-` which requires that
`secure=True`.
They cannot be combined.
Args:
key (str): The key (name) of the cookie.
host_prefix (bool, optional): Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True.
Defaults to `False`.
secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key.
This requires that secure=True. Defaults to `False`.
Raises:
ServerError: If both host_prefix and secure_prefix are set.
Returns:
str: The key with the appropriate prefix.
""" # noqa: E501
if host_prefix and secure_prefix:
raise ServerError(
"Both host_prefix and secure_prefix were requested. "
"A cookie should have only one prefix."
)
elif host_prefix:
key = cls.HOST_PREFIX + key
elif secure_prefix:
key = cls.SECURE_PREFIX + key
return key
@@ -0,0 +1,405 @@
"""
Sanic `provides a pattern
<https://sanicframework.org/guide/best-practices/exceptions.html#using-sanic-exceptions>`_
for providing a response when an exception occurs. However, if you do no handle
an exception, it will provide a fallback. There are three fallback types:
- HTML - *default*
- Text
- JSON
Setting ``app.config.FALLBACK_ERROR_FORMAT = "auto"`` will enable a switch that
will attempt to provide an appropriate response format based upon the
request type.
"""
from __future__ import annotations
import sys
import typing as t
from functools import partial
from traceback import extract_tb
from sanic.exceptions import BadRequest, SanicException
from sanic.helpers import STATUS_CODES
from sanic.log import deprecation, logger
from sanic.pages.error import ErrorPage
from sanic.response import html, json, text
dumps: t.Callable[..., str]
try:
from ujson import dumps
dumps = partial(dumps, escape_forward_slashes=False)
except ImportError: # noqa
from json import dumps
if t.TYPE_CHECKING:
from sanic import HTTPResponse, Request
DEFAULT_FORMAT = "auto"
FALLBACK_TEXT = """\
The application encountered an unexpected error and could not continue.\
"""
FALLBACK_STATUS = 500
JSON = "application/json"
class BaseRenderer:
"""Base class that all renderers must inherit from.
This class defines the structure for rendering objects, handling the core functionality that specific renderers may extend.
Attributes:
request (Request): The incoming request object that needs rendering.
exception (Exception): Any exception that occurred and needs to be rendered.
debug (bool): Flag indicating whether to render with debugging information.
Methods:
dumps: A static method that must be overridden by subclasses to define the specific rendering.
Args:
request (Request): The incoming request object that needs rendering.
exception (Exception): Any exception that occurred and needs to be rendered.
debug (bool): Flag indicating whether to render with debugging information.
""" # noqa: E501
dumps = staticmethod(dumps)
def __init__(self, request: Request, exception: Exception, debug: bool):
self.request = request
self.exception = exception
self.debug = debug
@property
def headers(self) -> t.Dict[str, str]:
"""The headers to be used for the response."""
if isinstance(self.exception, SanicException):
return getattr(self.exception, "headers", {})
return {}
@property
def status(self):
"""The status code to be used for the response."""
if isinstance(self.exception, SanicException):
return getattr(self.exception, "status_code", FALLBACK_STATUS)
return FALLBACK_STATUS
@property
def text(self):
"""The text to be used for the response."""
if self.debug or isinstance(self.exception, SanicException):
return str(self.exception)
return FALLBACK_TEXT
@property
def title(self):
"""The title to be used for the response."""
status_text = STATUS_CODES.get(self.status, b"Error Occurred").decode()
return f"{self.status}{status_text}"
def render(self) -> HTTPResponse:
"""Outputs the exception as a response.
Returns:
HTTPResponse: The response object.
"""
output = (
self.full
if self.debug and not getattr(self.exception, "quiet", False)
else self.minimal
)()
output.status = self.status
output.headers.update(self.headers)
return output
def minimal(self) -> HTTPResponse: # noqa
"""Provide a formatted message that is meant to not show any sensitive data or details.
This is the default fallback for production environments.
Returns:
HTTPResponse: The response object.
""" # noqa: E501
raise NotImplementedError
def full(self) -> HTTPResponse: # noqa
"""Provide a formatted message that has all details and is mean to be used primarily for debugging and non-production environments.
Returns:
HTTPResponse: The response object.
""" # noqa: E501
raise NotImplementedError
class HTMLRenderer(BaseRenderer):
"""Render an exception as HTML.
The default fallback type.
"""
def full(self) -> HTTPResponse:
page = ErrorPage(
debug=self.debug,
title=super().title,
text=super().text,
request=self.request,
exc=self.exception,
)
return html(page.render())
def minimal(self) -> HTTPResponse:
return self.full()
class TextRenderer(BaseRenderer):
"""Render an exception as plain text."""
OUTPUT_TEXT = "{title}\n{bar}\n{text}\n\n{body}"
SPACER = " "
def full(self) -> HTTPResponse:
return text(
self.OUTPUT_TEXT.format(
title=self.title,
text=self.text,
bar=("=" * len(self.title)),
body=self._generate_body(full=True),
)
)
def minimal(self) -> HTTPResponse:
return text(
self.OUTPUT_TEXT.format(
title=self.title,
text=self.text,
bar=("=" * len(self.title)),
body=self._generate_body(full=False),
)
)
@property
def title(self):
return f"⚠️ {super().title}"
def _generate_body(self, *, full):
lines = []
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
lines += [
f"{self.exception.__class__.__name__}: {self.exception} while "
f"handling path {self.request.path}",
f"Traceback of {self.request.app.name} "
"(most recent call last):\n",
]
while exc_value:
exceptions.append(self._format_exc(exc_value))
exc_value = exc_value.__cause__
lines += exceptions[::-1]
for attr, display in (("context", True), ("extra", bool(full))):
info = getattr(self.exception, attr, None)
if info and display:
lines += self._generate_object_display_list(info, attr)
return "\n".join(lines)
def _format_exc(self, exc):
frames = "\n\n".join(
[
f"{self.SPACER * 2}File {frame.filename}, "
f"line {frame.lineno}, in "
f"{frame.name}\n{self.SPACER * 2}{frame.line}"
for frame in extract_tb(exc.__traceback__)
]
)
return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}"
def _generate_object_display_list(self, obj, descriptor):
lines = [f"\n{descriptor.title()}"]
for key, value in obj.items():
display = self.dumps(value)
lines.append(f"{self.SPACER * 2}{key}: {display}")
return lines
class JSONRenderer(BaseRenderer):
"""Render an exception as JSON."""
def full(self) -> HTTPResponse:
output = self._generate_output(full=True)
return json(output, dumps=self.dumps)
def minimal(self) -> HTTPResponse:
output = self._generate_output(full=False)
return json(output, dumps=self.dumps)
def _generate_output(self, *, full):
output = {
"description": self.title,
"status": self.status,
"message": self.text,
}
for attr, display in (("context", True), ("extra", bool(full))):
info = getattr(self.exception, attr, None)
if info and display:
output[attr] = info
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
while exc_value:
exceptions.append(
{
"type": exc_value.__class__.__name__,
"exception": str(exc_value),
"frames": [
{
"file": frame.filename,
"line": frame.lineno,
"name": frame.name,
"src": frame.line,
}
for frame in extract_tb(exc_value.__traceback__)
],
}
)
exc_value = exc_value.__cause__
output["path"] = self.request.path
output["args"] = self.request.args
output["exceptions"] = exceptions[::-1]
return output
@property
def title(self):
return STATUS_CODES.get(self.status, b"Error Occurred").decode()
def escape(text):
"""Minimal HTML escaping, not for attribute values (unlike html.escape)."""
return f"{text}".replace("&", "&amp;").replace("<", "&lt;")
MIME_BY_CONFIG = {
"text": "text/plain",
"json": "application/json",
"html": "text/html",
}
CONFIG_BY_MIME = {v: k for k, v in MIME_BY_CONFIG.items()}
RENDERERS_BY_CONTENT_TYPE = {
"text/plain": TextRenderer,
"application/json": JSONRenderer,
"multipart/form-data": HTMLRenderer,
"text/html": HTMLRenderer,
}
# Handler source code is checked for which response types it returns with the
# route error_format="auto" (default) to determine which format to use.
RESPONSE_MAPPING = {
"json": "json",
"text": "text",
"html": "html",
"JSONResponse": "json",
"text/plain": "text",
"text/html": "html",
"application/json": "json",
}
def check_error_format(format):
"""Check that the format is known."""
if format not in MIME_BY_CONFIG and format != "auto":
raise SanicException(f"Unknown format: {format}")
def exception_response(
request: Request,
exception: Exception,
debug: bool,
fallback: str,
base: t.Type[BaseRenderer],
renderer: t.Optional[t.Type[BaseRenderer]] = None,
) -> HTTPResponse:
"""Render a response for the default FALLBACK exception handler."""
if not renderer:
mt = guess_mime(request, fallback)
renderer = RENDERERS_BY_CONTENT_TYPE.get(mt, base)
renderer = t.cast(t.Type[BaseRenderer], renderer)
return renderer(request, exception, debug).render()
def guess_mime(req: Request, fallback: str) -> str:
"""Guess the MIME type for the response based upon the request."""
# Attempt to find a suitable MIME format for the response.
# Insertion-ordered map of formats["html"] = "source of that suggestion"
formats = {}
name = ""
# Route error_format (by magic from handler code if auto, the default)
if req.route:
name = req.route.name
f = req.route.extra.error_format
if f in MIME_BY_CONFIG:
formats[f] = name
if not formats and fallback in MIME_BY_CONFIG:
formats[fallback] = "FALLBACK_ERROR_FORMAT"
# If still not known, check for the request for clues of JSON
if not formats and fallback == "auto" and req.accept.match(JSON):
if JSON in req.accept: # Literally, not wildcard
formats["json"] = "request.accept"
elif JSON in req.headers.getone("content-type", ""):
formats["json"] = "content-type"
# DEPRECATION: Remove this block in 24.3
else:
c = None
try:
c = req.json
except BadRequest:
pass
if c:
formats["json"] = "request.json"
deprecation(
"Response type was determined by the JSON content of "
"the request. This behavior is deprecated and will be "
"removed in v24.3. Please specify the format either by\n"
f' error_format="json" on route {name}, by\n'
' FALLBACK_ERROR_FORMAT = "json", or by adding header\n'
" accept: application/json to your requests.",
24.3,
)
# Any other supported formats
if fallback == "auto":
for k in MIME_BY_CONFIG:
if k not in formats:
formats[k] = "any"
mimes = [MIME_BY_CONFIG[k] for k in formats]
m = req.accept.match(*mimes)
if m:
format = CONFIG_BY_MIME[m.mime]
source = formats[format]
logger.debug(
"Error Page: The client accepts %s, using '%s' from %s",
m.header,
format,
source,
)
else:
logger.debug(
"Error Page: No format found, the client accepts %s",
repr(req.accept),
)
return m.mime
@@ -0,0 +1,655 @@
from asyncio import CancelledError
from collections.abc import Sequence
from os import PathLike
from typing import Any
from sanic.helpers import STATUS_CODES
from sanic.models.protocol_types import Range
class RequestCancelled(CancelledError):
quiet = True
class ServerKilled(Exception):
"""Exception Sanic server uses when killing a server process for something unexpected happening.""" # noqa: E501
quiet = True
class SanicException(Exception):
"""Generic exception that will generate an HTTP response when raised in the context of a request lifecycle.
Usually, it is best practice to use one of the more specific exceptions
than this generic one. Even when trying to raise a 500, it is generally
preferable to use `ServerError`.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`,
then the appropriate HTTP response status message will be used instead. Defaults to `None`.
status_code (Optional[int], optional): The HTTP response code to send, if applicable. If `None`,
then it will be 500. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed from the logs.
Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
Examples:
```python
raise SanicException(
"Something went wrong",
status_code=999,
context={
"info": "Some additional details to send to the client",
},
headers={
"X-Foo": "bar"
}
)
```
""" # noqa: E501
status_code: int = 500
quiet: bool | None = False
headers: dict[str, str] = {}
message: str = ""
def __init__(
self,
message: str | bytes | None = None,
status_code: int | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> None:
self.context = context
self.extra = extra
status_code = status_code or getattr(
self.__class__, "status_code", None
)
quiet = (
quiet
if quiet is not None
else getattr(self.__class__, "quiet", None)
)
headers = headers or getattr(self.__class__, "headers", {})
if message is None:
message = self.message
if not message and status_code:
msg = STATUS_CODES.get(status_code, b"")
message = msg.decode()
elif isinstance(message, bytes):
message = message.decode()
super().__init__(message)
self.status_code = status_code or self.status_code
self.quiet = quiet
self.headers = headers
try:
self.message = message
except AttributeError:
...
class HTTPException(SanicException):
"""A base class for other exceptions and should not be called directly."""
def __init__(
self,
message: str | bytes | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
) -> None:
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
class NotFound(HTTPException):
"""A base class for other exceptions and should not be called directly.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`,
then the appropriate HTTP response status message will be used instead. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed from the logs.
Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 404
quiet = True
class BadRequest(HTTPException):
"""400 Bad Request
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 400
quiet = True
InvalidUsage = BadRequest
BadURL = BadRequest
class MethodNotAllowed(HTTPException):
"""405 Method Not Allowed
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Method Not Allowed' will be sent. Defaults to `None`.
method (Optional[str], optional): The HTTP method that was used. Defaults to an empty string.
allowed_methods (Optional[Sequence[str]], optional): The HTTP methods that can be used instead of the
one that was attempted.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 405
quiet = True
def __init__(
self,
message: str | bytes | None = None,
method: str = "",
allowed_methods: Sequence[str] | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
if allowed_methods:
self.headers = {
**self.headers,
"Allow": ", ".join(allowed_methods),
}
self.method = method
self.allowed_methods = allowed_methods
MethodNotSupported = MethodNotAllowed
class ServerError(HTTPException):
"""500 Internal Server Error
A general server-side error has occurred. If no other HTTP exception is
appropriate, then this should be used
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 500
InternalServerError = ServerError
class ServiceUnavailable(HTTPException):
"""503 Service Unavailable
The server is currently unavailable (because it is overloaded or
down for maintenance). Generally, this is a temporary state.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 503
quiet = True
class URLBuildError(HTTPException):
"""500 Internal Server Error
An exception used by Sanic internals when unable to build a URL.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 500
class FileNotFound(NotFound):
"""404 Not Found
A specific form of :class:`.NotFound` that is specifically when looking
for a file on the file system at a known path.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Not Found' will be sent. Defaults to `None`.
path (Optional[PathLike], optional): The path, if any, to the file that could not
be found. Defaults to `None`.
relative_url (Optional[str], optional): A relative URL of the file. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
def __init__(
self,
message: str | bytes | None = None,
path: PathLike | None = None,
relative_url: str | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
self.path = path
self.relative_url = relative_url
class RequestTimeout(HTTPException):
"""408 Request Timeout
The Web server (running the Web site) thinks that there has been too
long an interval of time between 1) the establishment of an IP
connection (socket) between the client and the server and
2) the receipt of any data on that socket, so the server has dropped
the connection. The socket connection has actually been lost - the Web
server has 'timed out' on that particular socket connection.
This is an internal exception thrown by Sanic and should not be used
directly.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 408
quiet = True
class PayloadTooLarge(HTTPException):
"""413 Payload Too Large
This is an internal exception thrown by Sanic and should not be used
directly.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 413
quiet = True
class HeaderNotFound(BadRequest):
"""400 Bad Request
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
class InvalidHeader(BadRequest):
"""400 Bad Request
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
class RangeNotSatisfiable(HTTPException):
"""416 Range Not Satisfiable
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Range Not Satisfiable' will be sent. Defaults to `None`.
content_range (Optional[ContentRange], optional): An object meeting the :class:`.ContentRange` protocol
that has a `total` property. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 416
quiet = True
def __init__(
self,
message: str | bytes | None = None,
content_range: Range | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
if content_range is not None:
self.headers = {
**self.headers,
"Content-Range": f"bytes */{content_range.total}",
}
ContentRangeError = RangeNotSatisfiable
class ExpectationFailed(HTTPException):
"""417 Expectation Failed
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 417
quiet = True
HeaderExpectationFailed = ExpectationFailed
class Forbidden(HTTPException):
"""403 Forbidden
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 403
quiet = True
class InvalidRangeType(RangeNotSatisfiable):
"""416 Range Not Satisfiable
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
""" # noqa: E501
status_code = 416
quiet = True
class PyFileError(SanicException):
def __init__(
self,
file,
status_code: int | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
):
super().__init__(
"could not execute config file %s" % file,
status_code=status_code,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
class Unauthorized(HTTPException):
"""
**Status**: 401 Unauthorized
When present, additional keyword arguments may be used to complete
the WWW-Authentication header.
Args:
message (Optional[Union[str, bytes]], optional): The message to be sent to the client. If `None`
then the HTTP status 'Bad Request' will be sent. Defaults to `None`.
scheme (Optional[str], optional): Name of the authentication scheme to be used. Defaults to `None`.
quiet (Optional[bool], optional): When `True`, the error traceback will be suppressed
from the logs. Defaults to `None`.
context (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will be
sent to the client upon exception. Defaults to `None`.
extra (Optional[Dict[str, Any]], optional): Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode. Defaults to `None`.
headers (Optional[Dict[str, Any]], optional): Additional headers that should be sent with the HTTP
response. Defaults to `None`.
**challenges (Dict[str, Any]): Additional keyword arguments that will be used to complete the
WWW-Authentication header. Defaults to `None`.
Examples:
With a Basic auth-scheme, realm MUST be present:
```python
raise Unauthorized(
"Auth required.",
scheme="Basic",
realm="Restricted Area"
)
```
With a Digest auth-scheme, things are a bit more complicated:
```python
raise Unauthorized(
"Auth required.",
scheme="Digest",
realm="Restricted Area",
qop="auth, auth-int",
algorithm="MD5",
nonce="abcdef",
opaque="zyxwvu"
)
```
With a Bearer auth-scheme, realm is optional so you can write:
```python
raise Unauthorized("Auth required.", scheme="Bearer")
```
or, if you want to specify the realm:
```python
raise Unauthorized(
"Auth required.",
scheme="Bearer",
realm="Restricted Area"
)
```
""" # noqa: E501
status_code = 401
quiet = True
def __init__(
self,
message: str | bytes | None = None,
scheme: str | None = None,
*,
quiet: bool | None = None,
context: dict[str, Any] | None = None,
extra: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
**challenges,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
# if auth-scheme is specified, set "WWW-Authenticate" header
if scheme is not None:
values = [f'{k!s}="{v!s}"' for k, v in challenges.items()]
challenge = ", ".join(values)
self.headers = {
**self.headers,
"WWW-Authenticate": f"{scheme} {challenge}".rstrip(),
}
class LoadFileException(SanicException):
"""Exception raised when a file cannot be loaded."""
class InvalidSignal(SanicException):
"""Exception raised when an invalid signal is sent."""
class WebsocketClosed(SanicException):
"""Exception raised when a websocket is closed."""
quiet = True
message = "Client has closed the websocket connection"
@@ -0,0 +1,10 @@
from .content_range import ContentRangeHandler
from .directory import DirectoryHandler
from .error import ErrorHandler
__all__ = (
"ContentRangeHandler",
"DirectoryHandler",
"ErrorHandler",
)
@@ -0,0 +1,76 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from sanic.exceptions import (
HeaderNotFound,
InvalidRangeType,
RangeNotSatisfiable,
)
from sanic.models.protocol_types import Range
if TYPE_CHECKING:
from sanic import Request
class ContentRangeHandler(Range):
"""Parse and process the incoming request headers to extract the content range information.
Args:
request (Request): The incoming request object.
stats (os.stat_result): The stats of the file being served.
""" # noqa: E501
__slots__ = ("start", "end", "size", "total", "headers")
def __init__(self, request: Request, stats: os.stat_result) -> None:
self.total = stats.st_size
_range = request.headers.getone("range", None)
if _range is None:
raise HeaderNotFound("Range Header Not Found")
unit, _, value = tuple(map(str.strip, _range.partition("=")))
if unit != "bytes":
raise InvalidRangeType(
"{} is not a valid Range Type".format(unit), self
)
start_b, _, end_b = tuple(map(str.strip, value.partition("-")))
try:
self.start = int(start_b) if start_b else None
except ValueError:
raise RangeNotSatisfiable(
"'{}' is invalid for Content Range".format(start_b), self
)
try:
self.end = int(end_b) if end_b else None
except ValueError:
raise RangeNotSatisfiable(
"'{}' is invalid for Content Range".format(end_b), self
)
if self.end is None:
if self.start is None:
raise RangeNotSatisfiable(
"Invalid for Content Range parameters", self
)
else:
# this case represents `Content-Range: bytes 5-`
self.end = self.total - 1
else:
if self.start is None:
# this case represents `Content-Range: bytes -5`
self.start = self.total - self.end
self.end = self.total - 1
if self.start > self.end:
raise RangeNotSatisfiable(
"Invalid for Content Range parameters", self
)
self.size = self.end - self.start + 1
self.headers = {
"Content-Range": "bytes %s-%s/%s"
% (self.start, self.end, self.total)
}
def __bool__(self):
return hasattr(self, "size") and self.size > 0
@@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import Iterable, Sequence
from datetime import datetime
from operator import itemgetter
from pathlib import Path
from stat import S_ISDIR
from typing import cast
from urllib.parse import unquote
from sanic.exceptions import NotFound
from sanic.pages.directory_page import DirectoryPage, FileInfo
from sanic.request import Request
from sanic.response import file, html, redirect
def _is_path_within_root(path: Path, root: Path) -> bool:
"""Check if a path (after resolution) is within the root directory.
Returns False for:
- Broken symlinks (cannot be resolved)
- Paths that resolve outside the root directory
- Any errors during resolution
"""
try:
resolved = path.resolve()
resolved.relative_to(root.resolve())
except (ValueError, OSError, RuntimeError):
return False
else:
return True
class DirectoryHandler:
"""Serve files from a directory.
Args:
uri (str): The URI to serve the files at.
directory (Path): The directory to serve files from.
directory_view (bool): Whether to show a directory listing or not.
index (str | Sequence[str] | None): The index file(s) to
serve if the directory is requested. Defaults to None.
root_path (Optional[Path]): The root path for security checks.
Symlinks resolving outside this path will be hidden from
directory listings. Defaults to directory if not specified.
follow_external_symlink_files (bool): Whether to show file symlinks
pointing outside root in directory listings. Defaults to False.
follow_external_symlink_dirs (bool): Whether to show directory symlinks
pointing outside root in directory listings. Defaults to False.
"""
def __init__(
self,
uri: str,
directory: Path,
directory_view: bool = False,
index: str | Sequence[str] | None = None,
root_path: Path | None = None,
follow_external_symlink_files: bool = False,
follow_external_symlink_dirs: bool = False,
) -> None:
if isinstance(index, str):
index = [index]
elif index is None:
index = []
self.base = uri.strip("/")
self.directory = directory
self.directory_view = directory_view
self.index = tuple(index)
self.root_path = root_path if root_path is not None else directory
self.follow_external_symlink_files = follow_external_symlink_files
self.follow_external_symlink_dirs = follow_external_symlink_dirs
async def handle(self, request: Request, path: str):
"""Handle the request.
Args:
request (Request): The incoming request object.
path (str): The path to the file to serve.
Raises:
NotFound: If the file is not found.
IsADirectoryError: If the path is a directory and directory_view is False.
Returns:
Response: The response object.
""" # noqa: E501
current = unquote(path).strip("/")[len(self.base) :].strip("/") # noqa: E203
for file_name in self.index:
index_file = self.directory / current / file_name
if index_file.is_file():
return await file(index_file)
if self.directory_view:
return self._index(
self.directory / current, path, request.app.debug
)
if self.index:
raise NotFound("File not found")
raise IsADirectoryError(f"{self.directory.as_posix()} is a directory")
def _index(self, location: Path, path: str, debug: bool):
# Remove empty path elements, append slash
if "//" in path or not path.endswith("/"):
return redirect(
"/" + "".join([f"{p}/" for p in path.split("/") if p])
)
# Render file browser
page = DirectoryPage(self._iter_files(location), path, debug)
return html(page.render())
def _prepare_file(self, path: Path) -> dict[str, int | str] | None:
try:
stat = path.stat()
except OSError:
return None
modified = (
datetime.fromtimestamp(stat.st_mtime)
.isoformat()[:19]
.replace("T", " ")
)
is_dir = S_ISDIR(stat.st_mode)
icon = "📁" if is_dir else "📄"
file_name = path.name
if is_dir:
file_name += "/"
return {
"priority": is_dir * -1,
"file_name": file_name,
"icon": icon,
"file_access": modified,
"file_size": stat.st_size,
}
def _iter_files(self, location: Path) -> Iterable[FileInfo]:
prepared = []
for f in location.iterdir():
if f.is_symlink() and not _is_path_within_root(f, self.root_path):
# External symlink - check if allowed based on type
try:
is_dir = f.resolve().is_dir()
except OSError:
continue # Broken symlink
if is_dir and not self.follow_external_symlink_dirs:
continue
if not is_dir and not self.follow_external_symlink_files:
continue
file_info = self._prepare_file(f)
if file_info is not None:
prepared.append(file_info)
for item in sorted(prepared, key=itemgetter("priority", "file_name")):
del item["priority"]
yield cast(FileInfo, item)
@@ -0,0 +1,203 @@
from __future__ import annotations
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.exceptions import ServerError
from sanic.log import error_logger
from sanic.models.handler_types import RouteHandler
from sanic.request.types import Request
from sanic.response import text
from sanic.response.types import HTTPResponse
class ErrorHandler:
"""Process and handle all uncaught exceptions.
This error handling framework is built into the core that can be extended
by the developers to perform a wide range of tasks from recording the error
stats to reporting them to an external service that can be used for
realtime alerting system.
Args:
base (BaseRenderer): The renderer to use for the error pages.
""" # noqa: E501
def __init__(
self,
base: type[BaseRenderer] = TextRenderer,
):
self.cached_handlers: dict[
tuple[type[BaseException], str | None], RouteHandler | None
] = {}
self.debug = False
self.base = base
def _full_lookup(self, exception, route_name: str | None = None):
return self.lookup(exception, route_name)
def _add(
self,
key: tuple[type[BaseException], str | None],
handler: RouteHandler,
) -> None:
if key in self.cached_handlers:
exc, name = key
if name is None:
name = "__ALL_ROUTES__"
message = (
f"Duplicate exception handler definition on: route={name} "
f"and exception={exc}"
)
raise ServerError(message)
self.cached_handlers[key] = handler
def add(self, exception, handler, route_names: list[str] | None = None):
"""Add a new exception handler to an already existing handler object.
Args:
exception (sanic.exceptions.SanicException or Exception): Type
of exception that needs to be handled.
handler (function): Reference to the function that will
handle the exception.
Returns:
None
""" # noqa: E501
if route_names:
for route in route_names:
self._add((exception, route), handler)
else:
self._add((exception, None), handler)
def lookup(self, exception, route_name: str | None = None):
"""Lookup the existing instance of `ErrorHandler` and fetch the registered handler for a specific type of exception.
This method leverages a dict lookup to speedup the retrieval process.
Args:
exception (sanic.exceptions.SanicException or Exception): Type
of exception.
Returns:
Registered function if found, ``None`` otherwise.
""" # noqa: E501
exception_class = type(exception)
for name in (route_name, None):
exception_key = (exception_class, name)
handler = self.cached_handlers.get(exception_key)
if handler:
return handler
for name in (route_name, None):
for ancestor in type.mro(exception_class):
exception_key = (ancestor, name)
if exception_key in self.cached_handlers:
handler = self.cached_handlers[exception_key]
self.cached_handlers[(exception_class, route_name)] = (
handler
)
return handler
if ancestor is BaseException:
break
self.cached_handlers[(exception_class, route_name)] = None
handler = None
return handler
_lookup = _full_lookup
def response(self, request, exception):
"""Fetch and executes an exception handler and returns a response object.
Args:
request (sanic.request.Request): Instance of the request.
exception (sanic.exceptions.SanicException or Exception): Exception to handle.
Returns:
Wrap the return value obtained from the `default` function or the registered handler for that type of exception.
""" # noqa: E501
route_name = request.name if request else None
handler = self._lookup(exception, route_name)
response = None
try:
if handler:
response = handler(request, exception)
if response is None:
response = self.default(request, exception)
except Exception:
try:
url = repr(request.url)
except AttributeError: # no cov
url = "unknown"
response_message = (
'Exception raised in exception handler "%s" for uri: %s'
)
error_logger.exception(response_message, handler.__name__, url)
if self.debug:
return text(response_message % (handler.__name__, url), 500)
else:
return text("An error occurred while handling an error", 500)
return response
def default(self, request: Request, exception: Exception) -> HTTPResponse:
"""Provide a default behavior for the objects of ErrorHandler.
If a developer chooses to extend the ErrorHandler, they can
provide a custom implementation for this method to behave in a way
they see fit.
Args:
request (sanic.request.Request): Incoming request.
exception (sanic.exceptions.SanicException or Exception): Exception object.
Returns:
HTTPResponse: The response object.
Examples:
```python
class CustomErrorHandler(ErrorHandler):
def default(self, request: Request, exception: Exception) -> HTTPResponse:
# Custom logic for handling the exception and creating a response
custom_response = my_custom_logic(request, exception)
return custom_response
app = Sanic("MyApp", error_handler=CustomErrorHandler())
```
""" # noqa: E501
self.log(request, exception)
fallback = request.app.config.FALLBACK_ERROR_FORMAT
return exception_response(
request,
exception,
debug=self.debug,
base=self.base,
fallback=fallback,
)
@staticmethod
def log(request: Request, exception: Exception) -> None:
"""Logs information about an incoming request and the associated exception.
Args:
request (Request): The incoming request to be logged.
exception (Exception): The exception that occurred during the handling of the request.
Returns:
None
""" # noqa: E501
quiet = getattr(exception, "quiet", False)
noisy = getattr(request.app.config, "NOISY_EXCEPTIONS", False)
if quiet is False or noisy is True:
try:
url = repr(request.url)
except AttributeError: # no cov
url = "unknown"
error_logger.exception(
"Exception occurred while handling uri: %s", url
)
@@ -0,0 +1,550 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from typing import Any
from urllib.parse import unquote
from sanic.exceptions import InvalidHeader
from sanic.helpers import STATUS_CODES
# TODO:
# - the Options object should be a typed object to allow for less casting
# across the application (in request.py for example)
HeaderIterable = Iterable[tuple[str, Any]] # Values convertible to str
HeaderBytesIterable = Iterable[tuple[bytes, bytes]]
Options = dict[str, int | str] # key=value fields in various headers
OptionsIterable = Iterable[tuple[str, str]] # May contain duplicate keys
_token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"'
_param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII)
_ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}"
_ipv6_re = re.compile(_ipv6)
_host_re = re.compile(
r"((?:\[" + _ipv6 + r"\])|[a-zA-Z0-9.\-]{1,253})(?::(\d{1,5}))?"
)
# RFC's quoted-pair escapes are mostly ignored by browsers. Chrome, Firefox and
# curl all have different escaping, that we try to handle as well as possible,
# even though no client escapes in a way that would allow perfect handling.
# For more information, consult ../tests/test_requests.py
class MediaType:
"""A media type, as used in the Accept header.
This class is a representation of a media type, as used in the Accept
header. It encapsulates the type, subtype and any parameters, and
provides methods for matching against other media types.
Two separate methods are provided for searching the list:
- 'match' for finding the most preferred match (wildcards supported)
- operator 'in' for checking explicit matches (wildcards as literals)
Args:
type_ (str): The type of the media type.
subtype (str): The subtype of the media type.
**params (str): Any parameters for the media type.
"""
def __init__(
self,
type_: str,
subtype: str,
**params: str,
):
self.type = type_
self.subtype = subtype
self.q = float(params.get("q", "1.0"))
self.params = params
self.mime = f"{type_}/{subtype}"
self.key = (
-1 * self.q,
-1 * len(self.params),
self.subtype == "*",
self.type == "*",
)
def __repr__(self):
return self.mime + "".join(f";{k}={v}" for k, v in self.params.items())
def __eq__(self, other):
"""Check for mime (str or MediaType) identical type/subtype.
Parameters such as q are not considered."""
if isinstance(other, str):
# Give a friendly reminder if str contains parameters
if ";" in other:
raise ValueError("Use match() to compare with parameters")
return self.mime == other
if isinstance(other, MediaType):
# Ignore parameters silently with MediaType objects
return self.mime == other.mime
return NotImplemented
def match(
self,
mime_with_params: str | MediaType,
) -> MediaType | None:
"""Match this media type against another media type.
Check if this media type matches the given mime type/subtype.
Wildcards are supported both ways on both type and subtype.
If mime contains a semicolon, optionally followed by parameters,
the parameters of the two media types must match exactly.
.. note::
Use the `==` operator instead to check for literal matches
without expanding wildcards.
Args:
media_type (str): A type/subtype string to match.
Returns:
MediaType: Returns `self` if the media types are compatible.
None: Returns `None` if the media types are not compatible.
"""
mt = (
MediaType._parse(mime_with_params)
if isinstance(mime_with_params, str)
else mime_with_params
)
return (
self
if (
mt
# All parameters given in the other media type must match
and all(self.params.get(k) == v for k, v in mt.params.items())
# Subtype match
and (
self.subtype == mt.subtype
or self.subtype == "*"
or mt.subtype == "*"
)
# Type match
and (
self.type == mt.type or self.type == "*" or mt.type == "*"
)
)
else None
)
@property
def has_wildcard(self) -> bool:
"""Return True if this media type has a wildcard in it.
Returns:
bool: True if this media type has a wildcard in it.
"""
return any(part == "*" for part in (self.subtype, self.type))
@classmethod
def _parse(cls, mime_with_params: str) -> MediaType | None:
mtype = mime_with_params.strip()
if "/" not in mime_with_params:
return None
mime, *raw_params = mtype.split(";")
type_, subtype = mime.split("/", 1)
if not type_ or not subtype:
raise ValueError(f"Invalid media type: {mtype}")
params = {
key.strip(): value.strip()
for key, value in (param.split("=", 1) for param in raw_params)
}
return cls(type_.lstrip(), subtype.rstrip(), **params)
class Matched:
"""A matching result of a MIME string against a header.
This class is a representation of a matching result of a MIME string
against a header. It encapsulates the MIME string, the header, and
provides methods for matching against other MIME strings.
Args:
mime (str): The MIME string to match.
header (MediaType): The header to match against, if any.
"""
def __init__(self, mime: str, header: MediaType | None):
self.mime = mime
self.header = header
def __repr__(self):
return f"<{self} matched {self.header}>" if self else "<no match>"
def __str__(self):
return self.mime
def __bool__(self):
return self.header is not None
def __eq__(self, other: Any) -> bool:
try:
comp, other_accept = self._compare(other)
except TypeError:
return False
return bool(
comp
and (
(self.header and other_accept.header)
or (not self.header and not other_accept.header)
)
)
def _compare(self, other) -> tuple[bool, Matched]:
if isinstance(other, str):
parsed = Matched.parse(other)
if self.mime == other:
return True, parsed
other = parsed
if isinstance(other, Matched):
return self.header == other.header, other
raise TypeError(
"Comparison not supported between unequal "
f"mime types of '{self.mime}' and '{other}'"
)
def match(self, other: str | Matched) -> Matched | None:
"""Match this MIME string against another MIME string.
Check if this MIME string matches the given MIME string. Wildcards are supported both ways on both type and subtype.
Args:
other (str): A MIME string to match.
Returns:
Matched: Returns `self` if the MIME strings are compatible.
None: Returns `None` if the MIME strings are not compatible.
""" # noqa: E501
accept = Matched.parse(other) if isinstance(other, str) else other
if not self.header or not accept.header:
return None
if self.header.match(accept.header):
return accept
return None
@classmethod
def parse(cls, raw: str) -> Matched:
media_type = MediaType._parse(raw)
return cls(raw, media_type)
class AcceptList(list):
"""A list of media types, as used in the Accept header.
The Accept header entries are listed in order of preference, starting
with the most preferred. This class is a list of `MediaType` objects,
that encapsulate also the q value or any other parameters.
Two separate methods are provided for searching the list:
- 'match' for finding the most preferred match (wildcards supported)
- operator 'in' for checking explicit matches (wildcards as literals)
Args:
*args (MediaType): Any number of MediaType objects.
"""
def match(self, *mimes: str, accept_wildcards=True) -> Matched:
"""Find a media type accepted by the client.
This method can be used to find which of the media types requested by
the client is most preferred against the ones given as arguments.
The ordering of preference is set by:
1. The order set by RFC 7231, s. 5.3.2, giving a higher priority
to q values and more specific type definitions,
2. The order of the arguments (first is most preferred), and
3. The first matching entry on the Accept header.
Wildcards are matched both ways. A match is usually found, as the
Accept headers typically include `*/*`, in particular if the header
is missing, is not manually set, or if the client is a browser.
Note: the returned object behaves as a string of the mime argument
that matched, and is empty/falsy if no match was found. The matched
header entry `MediaType` or `None` is available as the `m` attribute.
Args:
mimes (List[str]): Any MIME types to search for in order of preference.
accept_wildcards (bool): Match Accept entries with wildcards in them.
Returns:
Match: A match object with the mime string and the MediaType object.
""" # noqa: E501
a = sorted(
(-acc.q, i, j, mime, acc)
for j, acc in enumerate(self)
if accept_wildcards or not acc.has_wildcard
for i, mime in enumerate(mimes)
if acc.match(mime)
)
return Matched(*(a[0][-2:] if a else ("", None)))
def __str__(self):
"""Format as Accept header value (parsed, not original)."""
return ", ".join(str(m) for m in self)
def parse_accept(accept: str | None) -> AcceptList:
"""Parse an Accept header and order the acceptable media types according to RFC 7231, s. 5.3.2
https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
Args:
accept (str): The Accept header value to parse.
Returns:
AcceptList: A list of MediaType objects, ordered by preference.
Raises:
InvalidHeader: If the header value is invalid.
""" # noqa: E501
if not accept:
if accept == "":
return AcceptList() # Empty header, accept nothing
accept = "*/*" # No header means that all types are accepted
try:
a = [
mt
for mt in [MediaType._parse(mtype) for mtype in accept.split(",")]
if mt
]
if not a:
raise ValueError
return AcceptList(sorted(a, key=lambda x: x.key))
except ValueError:
raise InvalidHeader(f"Invalid header value in Accept: {accept}")
def parse_content_header(value: str) -> tuple[str, Options]:
"""Parse content-type and content-disposition header values.
E.g. `form-data; name=upload; filename="file.txt"` to
('form-data', {'name': 'upload', 'filename': 'file.txt'})
Mostly identical to cgi.parse_header and werkzeug.parse_options_header
but runs faster and handles special characters better.
Unescapes %22 to `"` and %0D%0A to `\n` in field values.
Args:
value (str): The header value to parse.
Returns:
Tuple[str, Options]: The header value and a dict of options.
"""
pos = value.find(";")
if pos == -1:
options: dict[str, int | str] = {}
else:
options = {
m.group(1).lower(): (m.group(2) or m.group(3))
.replace("%22", '"')
.replace("%0D%0A", "\n")
for m in _param.finditer(value[pos:])
}
value = value[:pos]
return value.strip().lower(), options
# https://tools.ietf.org/html/rfc7230#section-3.2.6 and
# https://tools.ietf.org/html/rfc7239#section-4
# This regex is for *reversed* strings because that works much faster for
# right-to-left matching than the other way around. Be wary that all things are
# a bit backwards! _rparam matches forwarded pairs alike ";key=value"
_rparam = re.compile(f"(?:{_token}|{_quoted})={_token}\\s*($|[;,])", re.ASCII)
def parse_forwarded(headers, config) -> Options | None:
"""Parse RFC 7239 Forwarded headers.
The value of `by` or `secret` must match `config.FORWARDED_SECRET`
:return: dict with keys and values, or None if nothing matched
"""
header = headers.getall("forwarded", None)
secret = config.FORWARDED_SECRET
if header is None or not secret:
return None
header = ",".join(header) # Join multiple header lines
if secret not in header:
return None
# Loop over <separator><key>=<value> elements from right to left
sep = pos = None
options_list: list[tuple[str, str]] = []
found = False
for m in _rparam.finditer(header[::-1]):
# Start of new element? (on parser skips and non-semicolon right sep)
if m.start() != pos or sep != ";":
# Was the previous element (from right) what we wanted?
if found:
break
# Clear values and parse as new element
del options_list[:]
pos = m.end()
val_token, val_quoted, key, sep = m.groups()
key = key.lower()[::-1]
val = (val_token or val_quoted.replace('"\\', '"'))[::-1]
options_list.append((key, val))
if key in ("secret", "by") and val == secret:
found = True
# Check if we would return on next round, to avoid useless parse
if found and sep != ";":
break
# If secret was found, return the matching options in left-to-right order
return fwd_normalize(reversed(options_list)) if found else None
def parse_xforwarded(headers, config) -> Options | None:
"""Parse traditional proxy headers."""
real_ip_header = config.REAL_IP_HEADER
proxies_count = config.PROXIES_COUNT
addr = real_ip_header and headers.getone(real_ip_header, None)
if not addr and proxies_count:
assert proxies_count > 0
try:
# Combine, split and filter multiple headers' entries
forwarded_for = headers.getall(config.FORWARDED_FOR_HEADER)
proxies = [
p
for p in (
p.strip() for h in forwarded_for for p in h.split(",")
)
if p
]
addr = proxies[-proxies_count]
except (KeyError, IndexError):
pass
# No processing of other headers if no address is found
if not addr:
return None
def options():
yield "for", addr
for key, header in (
("proto", "x-scheme"),
("proto", "x-forwarded-proto"), # Overrides X-Scheme if present
("host", "x-forwarded-host"),
("port", "x-forwarded-port"),
("path", "x-forwarded-path"),
):
yield key, headers.getone(header, None)
return fwd_normalize(options())
def fwd_normalize(fwd: OptionsIterable) -> Options:
"""Normalize and convert values extracted from forwarded headers.
Args:
fwd (OptionsIterable): An iterable of key-value pairs.
Returns:
Options: A dict of normalized key-value pairs.
"""
ret: dict[str, int | str] = {}
for key, val in fwd:
if val is not None:
try:
if key in ("by", "for"):
ret[key] = fwd_normalize_address(val)
elif key in ("host", "proto"):
ret[key] = val.lower()
elif key == "port":
ret[key] = int(val)
elif key == "path":
ret[key] = unquote(val)
else:
ret[key] = val
except ValueError:
pass
return ret
def fwd_normalize_address(addr: str) -> str:
"""Normalize address fields of proxy headers.
Args:
addr (str): An address string.
Returns:
str: A normalized address string.
"""
if addr == "unknown":
raise ValueError() # omit unknown value identifiers
if addr.startswith("_"):
return addr # do not lower-case obfuscated strings
if _ipv6_re.fullmatch(addr):
addr = f"[{addr}]" # bracket IPv6
return addr.lower()
def parse_host(host: str) -> tuple[str | None, int | None]:
"""Split host:port into hostname and port.
Args:
host (str): A host string.
Returns:
Tuple[Optional[str], Optional[int]]: A tuple of hostname and port.
"""
m = _host_re.fullmatch(host)
if not m:
return None, None
host, port = m.groups()
return host.lower(), int(port) if port is not None else None
_HTTP1_STATUSLINES = [
b"HTTP/1.1 %d %b\r\n" % (status, STATUS_CODES.get(status, b"UNKNOWN"))
for status in range(1000)
]
def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
"""Format a HTTP/1.1 response header.
Args:
status (int): The HTTP status code.
headers (HeaderBytesIterable): An iterable of header tuples.
Returns:
bytes: The formatted response header.
"""
# Note: benchmarks show that here bytes concat is faster than bytearray,
# b"".join() or %-formatting. %timeit any changes you make.
ret = _HTTP1_STATUSLINES[status]
for h in headers:
ret += b"%b: %b\r\n" % h
ret += b"\r\n"
return ret
def parse_credentials(
header: str | None,
prefixes: list | tuple | set | None = None,
) -> tuple[str | None, str | None]:
"""Parses any header with the aim to retrieve any credentials from it.
Args:
header (Optional[str]): The header to parse.
prefixes (Optional[Union[List, Tuple, Set]], optional): The prefixes to look for. Defaults to None.
Returns:
Tuple[Optional[str], Optional[str]]: The prefix and the credentials.
""" # noqa: E501
if not prefixes or not isinstance(prefixes, (list, tuple, set)):
prefixes = ("Basic", "Bearer", "Token")
if header is not None:
for prefix in prefixes:
if prefix in header:
return prefix, header.partition(prefix)[-1].strip()
return None, header
@@ -0,0 +1,173 @@
"""Defines basics of HTTP standard."""
import sys
from functools import partial
from importlib import import_module
from inspect import ismodule
try:
from ujson import dumps as ujson_dumps
json_dumps = partial(ujson_dumps, escape_forward_slashes=False)
except ImportError:
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
from json import dumps
json_dumps = partial(dumps, separators=(",", ":"))
STATUS_CODES: dict[int, bytes] = {
100: b"Continue",
101: b"Switching Protocols",
102: b"Processing",
103: b"Early Hints",
200: b"OK",
201: b"Created",
202: b"Accepted",
203: b"Non-Authoritative Information",
204: b"No Content",
205: b"Reset Content",
206: b"Partial Content",
207: b"Multi-Status",
208: b"Already Reported",
226: b"IM Used",
300: b"Multiple Choices",
301: b"Moved Permanently",
302: b"Found",
303: b"See Other",
304: b"Not Modified",
305: b"Use Proxy",
307: b"Temporary Redirect",
308: b"Permanent Redirect",
400: b"Bad Request",
401: b"Unauthorized",
402: b"Payment Required",
403: b"Forbidden",
404: b"Not Found",
405: b"Method Not Allowed",
406: b"Not Acceptable",
407: b"Proxy Authentication Required",
408: b"Request Timeout",
409: b"Conflict",
410: b"Gone",
411: b"Length Required",
412: b"Precondition Failed",
413: b"Request Entity Too Large",
414: b"Request-URI Too Long",
415: b"Unsupported Media Type",
416: b"Requested Range Not Satisfiable",
417: b"Expectation Failed",
418: b"I'm a teapot",
422: b"Unprocessable Entity",
423: b"Locked",
424: b"Failed Dependency",
426: b"Upgrade Required",
428: b"Precondition Required",
429: b"Too Many Requests",
431: b"Request Header Fields Too Large",
451: b"Unavailable For Legal Reasons",
500: b"Internal Server Error",
501: b"Not Implemented",
502: b"Bad Gateway",
503: b"Service Unavailable",
504: b"Gateway Timeout",
505: b"HTTP Version Not Supported",
506: b"Variant Also Negotiates",
507: b"Insufficient Storage",
508: b"Loop Detected",
510: b"Not Extended",
511: b"Network Authentication Required",
}
# According to https://tools.ietf.org/html/rfc2616#section-7.1
_ENTITY_HEADERS = frozenset(
[
"allow",
"content-encoding",
"content-language",
"content-length",
"content-location",
"content-md5",
"content-range",
"content-type",
"expires",
"last-modified",
"extension-header",
]
)
# According to https://tools.ietf.org/html/rfc2616#section-13.5.1
_HOP_BY_HOP_HEADERS = frozenset(
[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
]
)
def has_message_body(status):
"""
According to the following RFC message body and length SHOULD NOT
be included in responses status 1XX, 204 and 304.
https://tools.ietf.org/html/rfc2616#section-4.4
https://tools.ietf.org/html/rfc2616#section-4.3
"""
return status not in (204, 304) and not (100 <= status < 200)
def is_entity_header(header):
"""Checks if the given header is an Entity Header"""
return header.lower() in _ENTITY_HEADERS
def is_hop_by_hop_header(header):
"""Checks if the given header is a Hop By Hop header"""
return header.lower() in _HOP_BY_HOP_HEADERS
def import_string(module_name, package=None):
"""
import a module or class by string path.
:module_name: str with path of module or path to import and
instantiate a class
:returns: a module object or one instance from class if
module_name is a valid path to class
"""
module, klass = module_name.rsplit(".", 1)
module = import_module(module, package=package)
obj = getattr(module, klass)
if ismodule(obj):
return obj
return obj()
def is_atty() -> bool:
return bool(sys.stdout and sys.stdout.isatty())
class Default:
"""
It is used to replace `None` or `object()` as a sentinel
that represents a default value. Sometimes we want to set
a value to `None` so we cannot use `None` to represent the
default value, and `object()` is hard to be typed.
"""
def __repr__(self):
return "<Default>"
def __str__(self) -> str:
return self.__repr__()
_default = Default()
@@ -0,0 +1,6 @@
from .constants import Stage
from .http1 import Http
from .http3 import Http3
__all__ = ("Http", "Stage", "Http3")
@@ -0,0 +1,30 @@
from enum import Enum, IntEnum
class Stage(Enum):
"""Enum for representing the stage of the request/response cycle
| ``IDLE`` Waiting for request
| ``REQUEST`` Request headers being received
| ``HANDLER`` Headers done, handler running
| ``RESPONSE`` Response headers sent, body in progress
| ``FAILED`` Unrecoverable state (error while sending response)
|
"""
IDLE = 0 # Waiting for request
REQUEST = 1 # Request headers being received
HANDLER = 3 # Headers done, handler running
RESPONSE = 4 # Response headers sent, body in progress
FAILED = 100 # Unrecoverable state (error while sending response)
class HTTP(IntEnum):
"""Enum for representing HTTP versions"""
VERSION_1 = 1
VERSION_3 = 3
def display(self) -> str:
value = 1.1 if self.value == 1 else self.value
return f"HTTP/{value}"
@@ -0,0 +1,619 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sanic.request import Request
from sanic.response import BaseHTTPResponse
from asyncio import CancelledError, sleep
from time import perf_counter
from sanic.compat import Header
from sanic.exceptions import (
BadRequest,
ExpectationFailed,
PayloadTooLarge,
RequestCancelled,
ServerError,
ServiceUnavailable,
)
from sanic.headers import format_http1_response
from sanic.helpers import has_message_body
from sanic.http.constants import Stage
from sanic.http.stream import Stream
from sanic.log import access_logger, error_logger, logger
from sanic.touchup import TouchUpMeta
HTTP_CONTINUE = b"HTTP/1.1 100 Continue\r\n\r\n"
class Http(Stream, metaclass=TouchUpMeta):
""" "Internal helper for managing the HTTP/1.1 request/response cycle.
Raises:
BadRequest: If the request body is malformed.
Exception: If the request is malformed.
ExpectationFailed: If the request is malformed.
PayloadTooLarge: If the request body exceeds the size limit.
RuntimeError: If the response status is invalid.
ServerError: If the handler does not produce a response.
ServerError: If the response is bigger than the content-length.
"""
HEADER_CEILING = 16_384
HEADER_MAX_SIZE = 0
__touchup__ = (
"http1_request_header",
"http1_response_header",
"read",
)
__slots__ = [
"_send",
"_receive_more",
"dispatch",
"recv_buffer",
"protocol",
"expecting_continue",
"stage",
"keep_alive",
"head_only",
"request",
"exception",
"url",
"request_body",
"request_bytes",
"request_bytes_left",
"response",
"response_func",
"response_size",
"response_bytes_left",
"upgrade_websocket",
"perft0",
]
def __init__(self, protocol):
self._send = protocol.send
self._receive_more = protocol.receive_more
self.recv_buffer = protocol.recv_buffer
self.protocol = protocol
self.keep_alive = True
self.stage: Stage = Stage.IDLE
self.dispatch = self.protocol.app.dispatch
def init_for_request(self):
"""Init/reset all per-request variables."""
self.exception = None
self.expecting_continue: bool = False
self.head_only = None
self.request_body = None
self.request_bytes = None
self.request_bytes_left = None
self.request_max_size = self.protocol.request_max_size
self.request: Request = None
self.response: BaseHTTPResponse = None
self.upgrade_websocket = False
self.url = None
self.perft0 = None
def __bool__(self):
"""Test if request handling is in progress"""
return self.stage in (Stage.HANDLER, Stage.RESPONSE)
async def http1(self):
"""HTTP 1.1 connection handler"""
# Handle requests while the connection stays reusable
while self.keep_alive and self.stage is Stage.IDLE:
self.init_for_request()
# Wait for incoming bytes (in IDLE stage)
if not self.recv_buffer:
await self._receive_more()
self.stage = Stage.REQUEST
try:
# Receive and handle a request
self.response_func = self.http1_response_header
await self.http1_request_header()
self.stage = Stage.HANDLER
self.perft0 = perf_counter()
self.request.conn_info = self.protocol.conn_info
await self.protocol.request_handler(self.request)
# Handler finished, response should've been sent
if self.stage is Stage.HANDLER and not self.upgrade_websocket:
raise ServerError("Handler produced no response")
if self.stage is Stage.RESPONSE:
await self.response.send(end_stream=True)
except CancelledError as exc:
# Write an appropriate response before exiting
if not self.protocol.transport:
logger.info(
f"Request: {self.request.method} {self.request.url} "
"stopped. Transport is closed."
)
return
e = (
RequestCancelled()
if self.protocol.conn_info.lost
else (self.exception or exc)
)
self.exception = None
self.keep_alive = False
await self.error_response(e)
except Exception as e:
# Write an error response
await self.error_response(e)
# Try to consume any remaining request body
if self.request_body:
if self.response and 200 <= self.response.status < 300:
error_logger.error(f"{self.request} body not consumed.")
# Limit the size because the handler may have set it infinite
self.request_max_size = min(
self.request_max_size, self.protocol.request_max_size
)
try:
async for _ in self:
pass
except PayloadTooLarge:
# We won't read the body and that may cause httpx and
# tests to fail. This little delay allows clients to push
# a small request into network buffers before we close the
# socket, so that they are then able to read the response.
await sleep(0.001)
self.keep_alive = False
# Clean up to free memory and for the next request
if self.request:
self.request.stream = None
if self.response:
self.response.stream = None
async def http1_request_header(self): # no cov
"""Receive and parse request header into self.request."""
# Receive until full header is in buffer
buf = self.recv_buffer
pos = 0
while True:
pos = buf.find(b"\r\n\r\n", pos)
if pos != -1:
break
pos = max(0, len(buf) - 3)
if pos >= self.HEADER_MAX_SIZE:
break
await self._receive_more()
if pos >= self.HEADER_MAX_SIZE:
raise PayloadTooLarge("Request header exceeds the size limit")
# Parse header content
try:
head = buf[:pos]
raw_headers = head.decode(errors="surrogateescape")
reqline, *split_headers = raw_headers.split("\r\n")
method, self.url, protocol = reqline.split(" ")
await self.dispatch(
"http.lifecycle.read_head",
inline=True,
context={"head": bytes(head)},
)
if protocol == "HTTP/1.1":
self.keep_alive = True
elif protocol == "HTTP/1.0":
self.keep_alive = False
else:
raise Exception # Raise a Bad Request on try-except
self.head_only = method.upper() == "HEAD"
request_body = False
headers = []
for name, value in (h.split(":", 1) for h in split_headers):
name, value = h = name.lower(), value.lstrip()
if name in ("content-length", "transfer-encoding"):
if request_body:
raise ValueError(
"Duplicate Content-Length or Transfer-Encoding"
)
request_body = True
elif name == "connection":
self.keep_alive = value.lower() == "keep-alive"
headers.append(h)
except Exception:
raise BadRequest("Bad Request")
if not self.protocol.app.config.KEEP_ALIVE:
self.keep_alive = False
headers_instance = Header(headers)
self.upgrade_websocket = (
headers_instance.getone("upgrade", "").lower() == "websocket"
)
try:
url_bytes = self.url.encode("ASCII")
except UnicodeEncodeError:
raise BadRequest("URL may only contain US-ASCII characters.")
# Prepare a Request object
request = self.protocol.request_class(
url_bytes=url_bytes,
headers=headers_instance,
head=bytes(head),
version=protocol[5:],
method=method,
transport=self.protocol.transport,
app=self.protocol.app,
)
self.protocol.request_class._current.set(request)
await self.dispatch(
"http.lifecycle.request",
inline=True,
context={"request": request},
)
# Prepare for request body
self.request_bytes_left = self.request_bytes = 0
if request_body:
headers = request.headers
expect = headers.getone("expect", None)
if expect is not None:
if expect.lower() == "100-continue":
self.expecting_continue = True
else:
raise ExpectationFailed(f"Unknown Expect: {expect}")
if headers.getone("transfer-encoding", None) == "chunked":
self.request_body = "chunked"
pos -= 2 # One CRLF stays in buffer
else:
self.request_body = True
try:
self.request_bytes_left = self.request_bytes = (
self._safe_int(headers["content-length"])
)
except Exception:
raise BadRequest("Bad content-length")
# Remove header and its trailing CRLF
del buf[: pos + 4]
self.request, request.stream = request, self
self.protocol.state["requests_count"] += 1
async def http1_response_header(
self, data: bytes, end_stream: bool
) -> None: # no cov
"""Format response header and send it."""
res = self.response
# Compatibility with simple response body
if not data and getattr(res, "body", None):
data, end_stream = res.body, True # type: ignore
size = len(data)
headers = res.headers
status = res.status
self.response_size = size
if not isinstance(status, int) or status < 200:
raise RuntimeError(f"Invalid response status {status!r}")
if not has_message_body(status):
# Header-only response status
self.response_func = None
if (
data
or not end_stream
or "content-length" in headers
or "transfer-encoding" in headers
):
data, size, end_stream = b"", 0, True
headers.pop("content-length", None)
headers.pop("transfer-encoding", None)
logger.warning(
f"Message body set in response on {self.request.path}. "
f"A {status} response may only have headers, no body."
)
elif self.head_only and "content-length" in headers:
self.response_func = None
elif end_stream:
# Non-streaming response (all in one block)
headers["content-length"] = size
self.response_func = None
elif "content-length" in headers:
# Streaming response with size known in advance
self.response_bytes_left = int(headers["content-length"]) - size
self.response_func = self.http1_response_normal
else:
# Length not known, use chunked encoding
headers["transfer-encoding"] = "chunked"
data = b"%x\r\n%b\r\n" % (size, data) if size else b""
self.response_func = self.http1_response_chunked
if self.head_only:
# Head request: don't send body
data = b""
self.response_func = self.head_response_ignored
headers["connection"] = "keep-alive" if self.keep_alive else "close"
# This header may be removed or modified by the AltSvcCheck Touchup
# service. At server start, we either remove this header from ever
# being assigned, or we change the value as required.
headers["alt-svc"] = ""
ret = format_http1_response(status, res.processed_headers)
if data:
ret += data
# Send a 100-continue if expected and not Expectation Failed
if self.expecting_continue:
self.expecting_continue = False
if status != 417:
ret = HTTP_CONTINUE + ret
# Send response
if self.protocol.access_log:
self.log_response()
await self._send(ret)
self.stage = Stage.IDLE if end_stream else Stage.RESPONSE
def head_response_ignored(self, data: bytes, end_stream: bool) -> None:
"""HEAD response: body data silently ignored."""
if end_stream:
self.response_func = None
self.stage = Stage.IDLE
async def http1_response_chunked(
self, data: bytes, end_stream: bool
) -> None:
"""Format a part of response body in chunked encoding."""
# Chunked encoding
size = len(data)
if end_stream:
await self._send(
b"%x\r\n%b\r\n0\r\n\r\n" % (size, data)
if size
else b"0\r\n\r\n"
)
self.response_func = None
self.stage = Stage.IDLE
elif size:
await self._send(b"%x\r\n%b\r\n" % (size, data))
async def http1_response_normal(
self, data: bytes, end_stream: bool
) -> None:
"""Format / keep track of non-chunked response."""
bytes_left = self.response_bytes_left - len(data)
if bytes_left <= 0:
if bytes_left < 0:
raise ServerError("Response was bigger than content-length")
await self._send(data)
self.response_func = None
self.stage = Stage.IDLE
else:
if end_stream:
raise ServerError("Response was smaller than content-length")
await self._send(data)
self.response_bytes_left = bytes_left
async def error_response(self, exception: Exception) -> None:
"""Handle response when exception encountered"""
# Disconnect after an error if in any other state than handler
if self.stage is not Stage.HANDLER:
self.keep_alive = False
# Request failure? Respond but then disconnect
if self.stage is Stage.REQUEST:
self.stage = Stage.HANDLER
# From request and handler states we can respond, otherwise be silent
if self.stage is Stage.HANDLER:
app = self.protocol.app
if self.request is None:
self.create_empty_request()
request_middleware = not isinstance(
exception, (ServiceUnavailable, RequestCancelled)
)
try:
await app.handle_exception(
self.request, exception, request_middleware
)
except Exception as e:
await app.handle_exception(self.request, e, False)
def create_empty_request(self) -> None:
"""Create an empty request object for error handling use.
Current error handling code needs a request object that won't exist
if an error occurred during before a request was received. Create a
bogus response for error handling use.
"""
# Reformat any URL already received with \xHH escapes for better logs
url_bytes = (
self.url.encode(errors="surrogateescape")
.decode("ASCII", errors="backslashreplace")
.encode("ASCII")
if self.url
else b"*"
)
# FIXME: Avoid this by refactoring error handling and response code
self.request = self.protocol.request_class(
url_bytes=url_bytes,
headers=Header({}),
version="1.1",
method="NONE",
transport=self.protocol.transport,
app=self.protocol.app,
)
self.request.stream = self
def log_response(self) -> None:
"""Helper method provided to enable the logging of responses in case if the `HttpProtocol.access_log` is enabled.""" # noqa: E501
req, res = self.request, self.response
extra = {
"status": getattr(res, "status", 0),
"byte": res.headers.get("content-length", 0)
if res.headers.get("transfer-encoding") != "chunked"
else "chunked",
"host": f"{id(self.protocol.transport):X}"[-5:-1] + "unx",
"request": "nil",
"duration": (
f" {1000 * (perf_counter() - self.perft0):.1f}ms"
if self.perft0 is not None
else ""
),
}
if ip := req.client_ip:
extra["host"] = f"{ip}:{req.port}"
extra["request"] = f"{req.method} {req.url}"
access_logger.info("", extra=extra)
# Request methods
async def __aiter__(self):
"""Async iterate over request body."""
while self.request_body:
data = await self.read()
if data:
yield data
async def read(self) -> bytes | None: # no cov
"""Read some bytes of request body."""
# Send a 100-continue if needed
if self.expecting_continue:
self.expecting_continue = False
await self._send(HTTP_CONTINUE)
# Receive request body chunk
buf = self.recv_buffer
if self.request_bytes_left == 0 and self.request_body == "chunked":
# Process a chunk header: \r\n<size>[;<chunk extensions>]\r\n
while True:
pos = buf.find(b"\r\n", 3)
if pos != -1:
break
if len(buf) > 64:
self.keep_alive = False
raise BadRequest("Bad chunked encoding")
await self._receive_more()
try:
raw = buf[2:pos].split(b";", 1)[0].decode()
size = self._safe_int(raw, 16)
except Exception:
self.keep_alive = False
raise BadRequest("Bad chunked encoding")
if size <= 0:
self.request_body = None
if size < 0:
self.keep_alive = False
raise BadRequest("Bad chunked encoding")
# Consume CRLF, chunk size 0 and the two CRLF that follow
pos += 4
# Might need to wait for the final CRLF
while len(buf) < pos:
await self._receive_more()
del buf[:pos]
return None
# Remove CRLF, chunk size and the CRLF that follows
del buf[: pos + 2]
self.request_bytes_left = size
self.request_bytes += size
# Request size limit
if self.request_bytes > self.request_max_size:
self.keep_alive = False
raise PayloadTooLarge("Request body exceeds the size limit")
# End of request body?
if not self.request_bytes_left:
self.request_body = None
return None
# At this point we are good to read/return up to request_bytes_left
if not buf:
await self._receive_more()
data = bytes(buf[: self.request_bytes_left])
size = len(data)
del buf[:size]
self.request_bytes_left -= size
await self.dispatch(
"http.lifecycle.read_body",
inline=True,
context={"body": data},
)
return data
# Response methods
def respond(self, response: BaseHTTPResponse) -> BaseHTTPResponse:
"""Initiate new streaming response.
Nothing is sent until the first send() call on the returned object, and
calling this function multiple times will just alter the response to be
given.
"""
if self.stage is not Stage.HANDLER:
self.stage = Stage.FAILED
raise RuntimeError("Response already started")
# Disconnect any earlier but unused response object
if self.response is not None:
self.response.stream = None
# Connect and return the response
self.response, response.stream = response, self
return response
@property
def send(self):
return self.response_func
@classmethod
def set_header_max_size(cls, *sizes: int):
cls.HEADER_MAX_SIZE = min(
*sizes,
cls.HEADER_CEILING,
)
@staticmethod
def _safe_int(value: str, base: int = 10) -> int:
if "-" in value or "+" in value or "_" in value:
raise ValueError
return int(value, base)
@@ -0,0 +1,423 @@
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from ssl import SSLContext
from typing import TYPE_CHECKING, Any, Callable, cast
from sanic.compat import Header
from sanic.constants import LocalCertCreator
from sanic.exceptions import (
BadRequest,
PayloadTooLarge,
SanicException,
ServerError,
)
from sanic.helpers import has_message_body
from sanic.http.constants import Stage
from sanic.http.stream import Stream
from sanic.http.tls.context import CertSelector, SanicSSLContext
from sanic.log import Colors, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.models.server_types import ConnInfo
try:
from aioquic.h0.connection import H0_ALPN, H0Connection
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import (
DatagramReceived,
DataReceived,
H3Event,
HeadersReceived,
WebTransportStreamDataReceived,
)
from aioquic.quic.configuration import QuicConfiguration
from aioquic.tls import SessionTicket
HTTP3_AVAILABLE = True
except ModuleNotFoundError: # no cov
HTTP3_AVAILABLE = False
if TYPE_CHECKING:
from sanic import Sanic
from sanic.request import Request
from sanic.response import BaseHTTPResponse
from sanic.server.protocols.http_protocol import Http3Protocol
HttpConnection = H0Connection | H3Connection
class HTTP3Transport(TransportProtocol):
"""HTTP/3 transport implementation."""
__slots__ = ("_protocol",)
def __init__(self, protocol: Http3Protocol):
self._protocol = protocol
def get_protocol(self) -> Http3Protocol:
return self._protocol
def get_extra_info(self, info: str, default: Any = None) -> Any:
if (
info in ("socket", "sockname", "peername")
and self._protocol._transport
):
return self._protocol._transport.get_extra_info(info, default)
elif info == "network_paths":
return self._protocol._quic._network_paths
elif info == "ssl_context":
return self._protocol.app.state.ssl
return default
class Receiver(ABC):
"""HTTP/3 receiver base class."""
future: asyncio.Future
def __init__(self, transmit, protocol, request: Request) -> None:
self.transmit = transmit
self.protocol = protocol
self.request = request
@abstractmethod
async def run(self): # no cov
...
class HTTPReceiver(Receiver, Stream):
"""HTTP/3 receiver implementation."""
stage: Stage
request: Request
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.request_body = None
self.stage = Stage.IDLE
self.headers_sent = False
self.response: BaseHTTPResponse | None = None
self.request_max_size = self.protocol.request_max_size
self.request_bytes = 0
async def run(self, exception: Exception | None = None):
"""Handle the request and response cycle."""
self.stage = Stage.HANDLER
self.head_only = self.request.method.upper() == "HEAD"
if exception:
logger.info( # no cov
f"{Colors.BLUE}[exception]: "
f"{Colors.RED}{exception}{Colors.END}",
exc_info=True,
extra={"verbosity": 1},
)
await self.error_response(exception)
else:
try:
logger.info( # no cov
f"{Colors.BLUE}[request]:{Colors.END} {self.request}",
extra={"verbosity": 1},
)
await self.protocol.request_handler(self.request)
except Exception as e: # no cov
# This should largely be handled within the request handler.
# But, just in case...
await self.run(e)
self.stage = Stage.IDLE
async def error_response(self, exception: Exception) -> None:
"""Handle response when exception encountered"""
# From request and handler states we can respond, otherwise be silent
app = self.protocol.app
await app.handle_exception(self.request, exception)
def _prepare_headers(
self, response: BaseHTTPResponse
) -> list[tuple[bytes, bytes]]:
size = len(response.body) if response.body else 0
headers = response.headers
status = response.status
if not has_message_body(status) and (
size
or "content-length" in headers
or "transfer-encoding" in headers
):
headers.pop("content-length", None)
headers.pop("transfer-encoding", None)
logger.warning( # no cov
f"Message body set in response on {self.request.path}. "
f"A {status} response may only have headers, no body."
)
elif "content-length" not in headers:
if size:
headers["content-length"] = size
else:
headers["transfer-encoding"] = "chunked"
headers = [
(b":status", str(response.status).encode()),
*response.processed_headers,
]
return headers
def send_headers(self) -> None:
"""Send response headers to client"""
logger.debug( # no cov
f"{Colors.BLUE}[send]: {Colors.GREEN}HEADERS{Colors.END}",
extra={"verbosity": 2},
)
if not self.response:
raise RuntimeError("no response")
response = self.response
headers = self._prepare_headers(response)
self.protocol.connection.send_headers(
stream_id=self.request.stream_id,
headers=headers,
)
self.headers_sent = True
self.stage = Stage.RESPONSE
if self.response.body and not self.head_only:
self._send(self.response.body, False)
elif self.head_only:
self.future.cancel()
def respond(self, response: BaseHTTPResponse) -> BaseHTTPResponse:
"""Prepare response to client"""
logger.debug( # no cov
f"{Colors.BLUE}[respond]:{Colors.END} {response}",
extra={"verbosity": 2},
)
if self.stage is not Stage.HANDLER:
self.stage = Stage.FAILED
raise RuntimeError("Response already started")
# Disconnect any earlier but unused response object
if self.response is not None:
self.response.stream = None
self.response, response.stream = response, self
return response
def receive_body(self, data: bytes) -> None:
"""Receive request body from client"""
self.request_bytes += len(data)
if self.request_bytes > self.request_max_size:
raise PayloadTooLarge("Request body exceeds the size limit")
self.request.body += data
async def send(self, data: bytes, end_stream: bool) -> None:
"""Send data to client"""
logger.debug( # no cov
f"{Colors.BLUE}[send]: {Colors.GREEN}data={data.decode()} "
f"end_stream={end_stream}{Colors.END}",
extra={"verbosity": 2},
)
self._send(data, end_stream)
def _send(self, data: bytes, end_stream: bool) -> None:
if not self.headers_sent:
self.send_headers()
if self.stage is not Stage.RESPONSE:
raise ServerError(f"not ready to send: {self.stage}")
# Chunked
if (
self.response
and self.response.headers.get("transfer-encoding") == "chunked"
):
size = len(data)
if end_stream:
data = (
b"%x\r\n%b\r\n0\r\n\r\n" % (size, data)
if size
else b"0\r\n\r\n"
)
elif size:
data = b"%x\r\n%b\r\n" % (size, data)
logger.debug( # no cov
f"{Colors.BLUE}[transmitting]{Colors.END}",
extra={"verbosity": 2},
)
self.protocol.connection.send_data(
stream_id=self.request.stream_id,
data=data,
end_stream=end_stream,
)
self.transmit()
if end_stream:
self.stage = Stage.IDLE
class WebsocketReceiver(Receiver): # noqa
"""Websocket receiver implementation."""
async def run(self): ...
class WebTransportReceiver(Receiver): # noqa
"""WebTransport receiver implementation."""
async def run(self): ...
class Http3:
"""Internal helper for managing the HTTP/3 request/response cycle"""
if HTTP3_AVAILABLE:
HANDLER_PROPERTY_MAPPING = {
DataReceived: "stream_id",
HeadersReceived: "stream_id",
DatagramReceived: "flow_id",
WebTransportStreamDataReceived: "session_id",
}
def __init__(
self,
protocol: Http3Protocol,
transmit: Callable[[], None],
) -> None:
self.protocol = protocol
self.transmit = transmit
self.receivers: dict[int, Receiver] = {}
def http_event_received(self, event: H3Event) -> None:
logger.debug( # no cov
f"{Colors.BLUE}[http_event_received]: "
f"{Colors.YELLOW}{event}{Colors.END}",
extra={"verbosity": 2},
)
receiver, created_new = self.get_or_make_receiver(event)
receiver = cast(HTTPReceiver, receiver)
if isinstance(event, HeadersReceived) and created_new:
receiver.future = asyncio.ensure_future(receiver.run())
elif isinstance(event, DataReceived):
try:
receiver.receive_body(event.data)
except Exception as e:
receiver.future.cancel()
receiver.future = asyncio.ensure_future(receiver.run(e))
else:
... # Intentionally here to help out Touchup
logger.debug( # no cov
f"{Colors.RED}DOING NOTHING{Colors.END}",
extra={"verbosity": 2},
)
def get_or_make_receiver(self, event: H3Event) -> tuple[Receiver, bool]:
if (
isinstance(event, HeadersReceived)
and event.stream_id not in self.receivers
):
request = self._make_request(event)
receiver = HTTPReceiver(self.transmit, self.protocol, request)
request.stream = receiver
self.receivers[event.stream_id] = receiver
return receiver, True
else:
ident = getattr(event, self.HANDLER_PROPERTY_MAPPING[type(event)])
return self.receivers[ident], False
def get_receiver_by_stream_id(self, stream_id: int) -> Receiver:
return self.receivers[stream_id]
def _make_request(self, event: HeadersReceived) -> Request:
try:
headers = Header(
(
(k.decode("ASCII"), v.decode(errors="surrogateescape"))
for k, v in event.headers
)
)
except UnicodeDecodeError:
raise BadRequest(
"Header names may only contain US-ASCII characters."
)
method = headers[":method"]
path = headers[":path"]
scheme = headers.pop(":scheme", "")
authority = headers.pop(":authority", "")
if authority:
headers["host"] = authority
try:
url_bytes = path.encode("ASCII")
except UnicodeEncodeError:
raise BadRequest("URL may only contain US-ASCII characters.")
transport = HTTP3Transport(self.protocol)
request = self.protocol.request_class(
url_bytes,
headers,
"3",
method,
transport,
self.protocol.app,
b"",
)
request.conn_info = ConnInfo(transport)
request._stream_id = event.stream_id
request._scheme = scheme
return request
class SessionTicketStore:
"""
Simple in-memory store for session tickets.
"""
def __init__(self) -> None:
self.tickets: dict[bytes, SessionTicket] = {}
def add(self, ticket: SessionTicket) -> None:
self.tickets[ticket.ticket] = ticket
def pop(self, label: bytes) -> SessionTicket | None:
return self.tickets.pop(label, None)
def get_config(app: Sanic, ssl: SanicSSLContext | CertSelector | SSLContext):
# TODO:
# - proper selection needed if service with multiple certs insted of
# just taking the first
if isinstance(ssl, CertSelector):
ssl = cast(SanicSSLContext, ssl.sanic_select[0])
if app.config.LOCAL_CERT_CREATOR is LocalCertCreator.TRUSTME:
raise SanicException(
"Sorry, you cannot currently use trustme as a local certificate "
"generator for an HTTP/3 server. This is not yet supported. You "
"should be able to use mkcert instead. For more information, see: "
"https://github.com/aiortc/aioquic/issues/295."
)
if not isinstance(ssl, SanicSSLContext):
raise SanicException("SSLContext is not SanicSSLContext")
config = QuicConfiguration(
alpn_protocols=H3_ALPN + H0_ALPN + ["siduck"],
is_client=False,
max_datagram_frame_size=65536,
)
password = app.config.TLS_CERT_PASSWORD or None
config.load_cert_chain(
ssl.sanic["cert"], ssl.sanic["key"], password=password
)
return config
@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sanic.http.constants import Stage
if TYPE_CHECKING:
from sanic.response import BaseHTTPResponse
from sanic.server.protocols.http_protocol import HttpProtocol
class Stream:
stage: Stage
response: BaseHTTPResponse | None
protocol: HttpProtocol
url: str | None
request_body: bytes | None
request_max_size: int | float
__touchup__: tuple[str, ...] = tuple()
__slots__ = ("request_max_size",)
def respond(
self, response: BaseHTTPResponse
) -> BaseHTTPResponse: # no cov
raise NotImplementedError("Not implemented")
@@ -0,0 +1,5 @@
from .context import process_to_context
from .creators import get_ssl_context
__all__ = ("get_ssl_context", "process_to_context")
@@ -0,0 +1,210 @@
from __future__ import annotations
import os
import ssl
from collections.abc import Iterable
from typing import Any
from sanic.log import logger
# Only allow secure ciphers, notably leaving out AES-CBC mode
# OpenSSL chooses ECDSA or RSA depending on the cert in use
CIPHERS_TLS12 = [
"ECDHE-ECDSA-CHACHA20-POLY1305",
"ECDHE-ECDSA-AES256-GCM-SHA384",
"ECDHE-ECDSA-AES128-GCM-SHA256",
"ECDHE-RSA-CHACHA20-POLY1305",
"ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-RSA-AES128-GCM-SHA256",
]
def create_context(
certfile: str | None = None,
keyfile: str | None = None,
password: str | None = None,
purpose: ssl.Purpose = ssl.Purpose.CLIENT_AUTH,
) -> ssl.SSLContext:
"""Create a context with secure crypto and HTTP/1.1 in protocols."""
context = ssl.create_default_context(purpose=purpose)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.set_ciphers(":".join(CIPHERS_TLS12))
context.set_alpn_protocols(["http/1.1"])
if purpose is ssl.Purpose.CLIENT_AUTH:
context.sni_callback = server_name_callback
if certfile and keyfile:
context.load_cert_chain(certfile, keyfile, password)
return context
def shorthand_to_ctx(
ctxdef: None | ssl.SSLContext | dict | str,
) -> ssl.SSLContext | None:
"""Convert an ssl argument shorthand to an SSLContext object."""
if ctxdef is None or isinstance(ctxdef, ssl.SSLContext):
return ctxdef
if isinstance(ctxdef, str):
return load_cert_dir(ctxdef)
if isinstance(ctxdef, dict):
return CertSimple(**ctxdef)
raise ValueError(
f"Invalid ssl argument {type(ctxdef)}."
" Expecting a list of certdirs, a dict or an SSLContext."
)
def process_to_context(
ssldef: None | ssl.SSLContext | dict | str | list | tuple,
) -> ssl.SSLContext | None:
"""Process app.run ssl argument from easy formats to full SSLContext."""
return (
CertSelector(map(shorthand_to_ctx, ssldef))
if isinstance(ssldef, (list, tuple))
else shorthand_to_ctx(ssldef)
)
def load_cert_dir(p: str) -> ssl.SSLContext:
if os.path.isfile(p):
raise ValueError(f"Certificate folder expected but {p} is a file.")
keyfile = os.path.join(p, "privkey.pem")
certfile = os.path.join(p, "fullchain.pem")
if not os.access(keyfile, os.R_OK):
raise ValueError(
f"Certificate not found or permission denied {keyfile}"
)
if not os.access(certfile, os.R_OK):
raise ValueError(
f"Certificate not found or permission denied {certfile}"
)
return CertSimple(certfile, keyfile)
def find_cert(self: CertSelector, server_name: str):
"""Find the first certificate that matches the given SNI.
:raises ssl.CertificateError: No matching certificate found.
:return: A matching ssl.SSLContext object if found."""
if not server_name:
if self.sanic_fallback:
return self.sanic_fallback
raise ValueError(
"The client provided no SNI to match for certificate."
)
for ctx in self.sanic_select:
if match_hostname(ctx, server_name):
return ctx
if self.sanic_fallback:
return self.sanic_fallback
raise ValueError(f"No certificate found matching hostname {server_name!r}")
def match_hostname(ctx: ssl.SSLContext | CertSelector, hostname: str) -> bool:
"""Match names from CertSelector against a received hostname."""
# Local certs are considered trusted, so this can be less pedantic
# and thus faster than the deprecated ssl.match_hostname function is.
names = dict(getattr(ctx, "sanic", {})).get("names", [])
hostname = hostname.lower()
for name in names:
if name.startswith("*."):
if hostname.split(".", 1)[-1] == name[2:]:
return True
elif name == hostname:
return True
return False
def selector_sni_callback(
sslobj: ssl.SSLObject, server_name: str, ctx: CertSelector
) -> int | None:
"""Select a certificate matching the SNI."""
# Call server_name_callback to store the SNI on sslobj
server_name_callback(sslobj, server_name, ctx)
# Find a new context matching the hostname
try:
sslobj.context = find_cert(ctx, server_name)
except ValueError as e:
logger.warning(f"Rejecting TLS connection: {e}")
# This would show ERR_SSL_UNRECOGNIZED_NAME_ALERT on client side if
# asyncio/uvloop did proper SSL shutdown. They don't.
return ssl.ALERT_DESCRIPTION_UNRECOGNIZED_NAME
return None # mypy complains without explicit return
def server_name_callback(
sslobj: ssl.SSLObject, server_name: str, ctx: ssl.SSLContext
) -> None:
"""Store the received SNI as sslobj.sanic_server_name."""
sslobj.sanic_server_name = server_name # type: ignore
class SanicSSLContext(ssl.SSLContext):
sanic: dict[str, os.PathLike]
@classmethod
def create_from_ssl_context(cls, context: ssl.SSLContext):
context.__class__ = cls
return context
class CertSimple(SanicSSLContext):
"""A wrapper for creating SSLContext with a sanic attribute."""
sanic: dict[str, Any]
def __new__(cls, cert, key, **kw):
# try common aliases, rename to cert/key
certfile = kw["cert"] = kw.pop("certificate", None) or cert
keyfile = kw["key"] = kw.pop("keyfile", None) or key
password = kw.get("password", None)
if not certfile or not keyfile:
raise ValueError("SSL dict needs filenames for cert and key.")
subject = {}
if "names" not in kw:
cert = ssl._ssl._test_decode_cert(certfile) # type: ignore
kw["names"] = [
name
for t, name in cert["subjectAltName"]
if t in ["DNS", "IP Address"]
]
subject = {k: v for item in cert["subject"] for k, v in item}
self = create_context(certfile, keyfile, password)
self.__class__ = cls
self.sanic = {**subject, **kw}
return self
def __init__(self, cert, key, **kw):
pass # Do not call super().__init__ because it is already initialized
class CertSelector(ssl.SSLContext):
"""Automatically select SSL certificate based on the hostname that the
client is trying to access, via SSL SNI. Paths to certificate folders
with privkey.pem and fullchain.pem in them should be provided, and
will be matched in the order given whenever there is a new connection.
"""
def __new__(cls, ctxs):
return super().__new__(cls)
def __init__(self, ctxs: Iterable[ssl.SSLContext | None]):
super().__init__()
self.sni_callback = selector_sni_callback # type: ignore
self.sanic_select = []
self.sanic_fallback = None
all_names = []
for i, ctx in enumerate(ctxs):
if not ctx:
continue
names = dict(getattr(ctx, "sanic", {})).get("names", [])
all_names += names
self.sanic_select.append(ctx)
if i == 0:
self.sanic_fallback = ctx
if not all_names:
raise ValueError(
"No certificates with SubjectAlternativeNames found."
)
logger.info(f"Certificate vhosts: {', '.join(all_names)}")
@@ -0,0 +1,287 @@
from __future__ import annotations
import ssl
import subprocess
import sys
from abc import ABC, abstractmethod
from contextlib import suppress
from pathlib import Path
from tempfile import mkdtemp
from types import ModuleType
from typing import TYPE_CHECKING, cast
from sanic.application.constants import Mode
from sanic.application.spinner import loading
from sanic.constants import (
DEFAULT_LOCAL_TLS_CERT,
DEFAULT_LOCAL_TLS_KEY,
LocalCertCreator,
)
from sanic.exceptions import SanicException
from sanic.helpers import Default
from sanic.http.tls.context import CertSimple, SanicSSLContext
try:
import trustme
TRUSTME_INSTALLED = True
except (ImportError, ModuleNotFoundError):
trustme = ModuleType("trustme")
TRUSTME_INSTALLED = False
if TYPE_CHECKING:
from sanic import Sanic
# Only allow secure ciphers, notably leaving out AES-CBC mode
# OpenSSL chooses ECDSA or RSA depending on the cert in use
CIPHERS_TLS12 = [
"ECDHE-ECDSA-CHACHA20-POLY1305",
"ECDHE-ECDSA-AES256-GCM-SHA384",
"ECDHE-ECDSA-AES128-GCM-SHA256",
"ECDHE-RSA-CHACHA20-POLY1305",
"ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-RSA-AES128-GCM-SHA256",
]
def _make_path(maybe_path: Path | str, tmpdir: Path | None) -> Path:
if isinstance(maybe_path, Path):
return maybe_path
else:
path = Path(maybe_path)
if not path.exists():
if not tmpdir:
raise RuntimeError("Reached an unknown state. No tmpdir.")
return tmpdir / maybe_path
return path
def get_ssl_context(app: Sanic, ssl: ssl.SSLContext | None) -> ssl.SSLContext:
if ssl:
return ssl
if app.state.mode is Mode.PRODUCTION:
raise SanicException(
"Cannot run Sanic as an HTTPS server in PRODUCTION mode "
"without passing a TLS certificate. If you are developing "
"locally, please enable DEVELOPMENT mode and Sanic will "
"generate a localhost TLS certificate. For more information "
"please see: https://sanic.dev/en/guide/deployment/development."
"html#automatic-tls-certificate."
)
creator = CertCreator.select(
app,
cast(LocalCertCreator, app.config.LOCAL_CERT_CREATOR),
app.config.LOCAL_TLS_KEY,
app.config.LOCAL_TLS_CERT,
)
context = creator.generate_cert(app.config.LOCALHOST)
return context
class CertCreator(ABC):
def __init__(self, app, key, cert) -> None:
self.app = app
self.key = key
self.cert = cert
self.tmpdir = None
if isinstance(self.key, Default) or isinstance(self.cert, Default):
self.tmpdir = Path(mkdtemp())
key = (
DEFAULT_LOCAL_TLS_KEY
if isinstance(self.key, Default)
else self.key
)
cert = (
DEFAULT_LOCAL_TLS_CERT
if isinstance(self.cert, Default)
else self.cert
)
self.key_path = _make_path(key, self.tmpdir)
self.cert_path = _make_path(cert, self.tmpdir)
@abstractmethod
def check_supported(self) -> None: # no cov
...
@abstractmethod
def generate_cert(self, localhost: str) -> ssl.SSLContext: # no cov
...
@classmethod
def select(
cls,
app: Sanic,
cert_creator: LocalCertCreator,
local_tls_key,
local_tls_cert,
) -> CertCreator:
creator: CertCreator | None = None
cert_creator_options: tuple[
tuple[type[CertCreator], LocalCertCreator], ...
] = (
(MkcertCreator, LocalCertCreator.MKCERT),
(TrustmeCreator, LocalCertCreator.TRUSTME),
)
for creator_class, local_creator in cert_creator_options:
creator = cls._try_select(
app,
creator,
creator_class,
local_creator,
cert_creator,
local_tls_key,
local_tls_cert,
)
if creator:
break
if not creator:
raise SanicException(
"Sanic could not find package to create a TLS certificate. "
"You must have either mkcert or trustme installed. See "
"https://sanic.dev/en/guide/deployment/development.html"
"#automatic-tls-certificate for more details."
)
return creator
@staticmethod
def _try_select(
app: Sanic,
creator: CertCreator | None,
creator_class: type[CertCreator],
creator_requirement: LocalCertCreator,
creator_requested: LocalCertCreator,
local_tls_key,
local_tls_cert,
):
if creator or (
creator_requested is not LocalCertCreator.AUTO
and creator_requested is not creator_requirement
):
return creator
instance = creator_class(app, local_tls_key, local_tls_cert)
try:
instance.check_supported()
except SanicException:
if creator_requested is creator_requirement:
raise
else:
return None
return instance
class MkcertCreator(CertCreator):
def check_supported(self) -> None:
try:
subprocess.run( # nosec B603 B607
["mkcert", "-help"],
check=True,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
)
except Exception as e:
raise SanicException(
"Sanic is attempting to use mkcert to generate local TLS "
"certificates since you did not supply a certificate, but "
"one is required. Sanic cannot proceed since mkcert does not "
"appear to be installed. Alternatively, you can use trustme. "
"Please install mkcert, trustme, or supply TLS certificates "
"to proceed. Installation instructions can be found here: "
"https://github.com/FiloSottile/mkcert.\n"
"Find out more information about your options here: "
"https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
) from e
def generate_cert(self, localhost: str) -> ssl.SSLContext:
try:
if not self.cert_path.exists():
message = "Generating TLS certificate"
# TODO: Validate input for security
with loading(message):
cmd = [
"mkcert",
"-key-file",
str(self.key_path),
"-cert-file",
str(self.cert_path),
localhost,
]
resp = subprocess.run( # nosec B603
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
sys.stdout.write("\r" + " " * (len(message) + 4))
sys.stdout.flush()
sys.stdout.write(resp.stdout)
finally:
@self.app.main_process_stop
async def cleanup(*_): # no cov
if self.tmpdir:
with suppress(FileNotFoundError):
self.key_path.unlink()
self.cert_path.unlink()
self.tmpdir.rmdir()
context = CertSimple(self.cert_path, self.key_path)
context.sanic["creator"] = "mkcert"
context.sanic["localhost"] = localhost
SanicSSLContext.create_from_ssl_context(context)
return context
class TrustmeCreator(CertCreator):
def check_supported(self) -> None:
if not TRUSTME_INSTALLED:
raise SanicException(
"Sanic is attempting to use trustme to generate local TLS "
"certificates since you did not supply a certificate, but "
"one is required. Sanic cannot proceed since trustme does not "
"appear to be installed. Alternatively, you can use mkcert. "
"Please install mkcert, trustme, or supply TLS certificates "
"to proceed. Installation instructions can be found here: "
"https://github.com/python-trio/trustme.\n"
"Find out more information about your options here: "
"https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
)
def generate_cert(self, localhost: str) -> ssl.SSLContext:
context = SanicSSLContext.create_from_ssl_context(
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
)
context.sanic = {
"cert": self.cert_path.absolute(),
"key": self.key_path.absolute(),
}
ca = trustme.CA()
server_cert = ca.issue_cert(localhost)
server_cert.configure_cert(context)
ca.configure_trust(context)
ca.cert_pem.write_to_path(str(self.cert_path.absolute()))
server_cert.private_key_and_cert_chain_pem.write_to_path(
str(self.key_path.absolute())
)
context.sanic["creator"] = "trustme"
context.sanic["localhost"] = localhost
return context
@@ -0,0 +1,24 @@
from sanic.logging.color import Colors
from sanic.logging.default import LOGGING_CONFIG_DEFAULTS
from sanic.logging.deprecation import deprecation
from sanic.logging.filter import VerbosityFilter
from sanic.logging.loggers import (
access_logger,
error_logger,
logger,
server_logger,
websockets_logger,
)
__all__ = (
"deprecation",
"logger",
"access_logger",
"error_logger",
"server_logger",
"websockets_logger",
"VerbosityFilter",
"Colors",
"LOGGING_CONFIG_DEFAULTS",
)
@@ -0,0 +1,68 @@
import logging
import os
import sys
from enum import Enum
from typing import TYPE_CHECKING
from sanic.helpers import is_atty
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
if not TYPE_CHECKING:
from enum import StrEnum
COLORIZE = is_atty() and not os.environ.get("SANIC_NO_COLOR")
class Colors(StrEnum): # no cov
"""
Colors for log messages. If the output is not a TTY, the colors will be
disabled.
Can be used like this:
.. code-block:: python
from sanic.log import logger, Colors
logger.info(f"{Colors.GREEN}This is a green message{Colors.END}")
Attributes:
END: Reset the color
BOLD: Bold text
BLUE: Blue text
GREEN: Green text
PURPLE: Purple text
RED: Red text
SANIC: Sanic pink
YELLOW: Yellow text
GREY: Grey text
"""
END = "\033[0m" if COLORIZE else ""
BOLD = "\033[1m" if COLORIZE else ""
BLUE = "\033[34m" if COLORIZE else ""
GREEN = "\033[32m" if COLORIZE else ""
PURPLE = "\033[35m" if COLORIZE else ""
CYAN = "\033[36m" if COLORIZE else ""
RED = "\033[31m" if COLORIZE else ""
YELLOW = "\033[33m" if COLORIZE else ""
GREY = "\033[38;5;240m" if COLORIZE else ""
SANIC = "\033[38;2;255;13;104m" if COLORIZE else ""
LEVEL_COLORS = {
logging.DEBUG: Colors.BLUE,
logging.WARNING: Colors.YELLOW,
logging.ERROR: Colors.RED,
logging.CRITICAL: Colors.RED + Colors.BOLD,
}
@@ -0,0 +1,60 @@
import sys
from typing import Any
LOGGING_CONFIG_DEFAULTS: dict[str, Any] = dict( # no cov
version=1,
disable_existing_loggers=False,
loggers={
"sanic.root": {"level": "INFO", "handlers": ["console"]},
"sanic.error": {
"level": "INFO",
"handlers": ["error_console"],
"propagate": True,
"qualname": "sanic.error",
},
"sanic.access": {
"level": "INFO",
"handlers": ["access_console"],
"propagate": True,
"qualname": "sanic.access",
},
"sanic.server": {
"level": "INFO",
"handlers": ["console"],
"propagate": True,
"qualname": "sanic.server",
},
"sanic.websockets": {
"level": "INFO",
"handlers": ["console"],
"propagate": True,
"qualname": "sanic.websockets",
},
},
handlers={
"console": {
"class": "logging.StreamHandler",
"formatter": "generic",
"stream": sys.stdout,
},
"error_console": {
"class": "logging.StreamHandler",
"formatter": "generic",
"stream": sys.stderr,
},
"access_console": {
"class": "logging.StreamHandler",
"formatter": "access",
"stream": sys.stdout,
},
},
formatters={
"generic": {"class": "sanic.logging.formatter.AutoFormatter"},
"access": {"class": "sanic.logging.formatter.AutoAccessFormatter"},
},
)
"""
Default logging configuration
"""
@@ -0,0 +1,33 @@
from warnings import warn
from sanic.helpers import is_atty
from sanic.logging.color import Colors
def deprecation(message: str, version: float): # no cov
"""
Add a deprecation notice
Example when a feature is being removed. In this case, version
should be AT LEAST next version + 2
.. code-block:: python
deprecation("Helpful message", 99.9)
Example when a feature is deprecated but not being removed:
.. code-block:: python
deprecation("Helpful message", 0)
Args:
message (str): Deprecation message
version (float): Version when the feature will be removed
"""
version_display = f" v{version}" if version else ""
version_info = f"[DEPRECATION{version_display}] "
if is_atty():
version_info = f"{Colors.RED}{version_info}"
message = f"{Colors.YELLOW}{message}{Colors.END}"
warn(version_info + message, DeprecationWarning)
@@ -0,0 +1,13 @@
import logging
class VerbosityFilter(logging.Filter):
"""
Filter log records based on verbosity level.
"""
verbosity: int = 0
def filter(self, record: logging.LogRecord) -> bool:
verbosity = getattr(record, "verbosity", 0)
return verbosity <= self.verbosity
@@ -0,0 +1,387 @@
from __future__ import annotations
import logging
import os
import re
from sanic.helpers import is_atty, json_dumps
from sanic.logging.color import LEVEL_COLORS
from sanic.logging.color import Colors as c
CONTROL_RE = re.compile(r"\033\[[0-9;]*\w")
CONTROL_LIMIT_IDENT = "\033[1000D\033[{limit}C"
CONTROL_LIMIT_START = "\033[1000D\033[{start}C\033[K"
CONTROL_LIMIT_END = "\033[1000C\033[{right}D\033[K"
EXCEPTION_LINE_RE = re.compile(r"^(?P<exc>.*?): (?P<message>.*)$")
FILE_LINE_RE = re.compile(
r"File \"(?P<path>.*?)\", line (?P<line_num>\d+), in (?P<location>.*)"
)
DEFAULT_FIELDS = set(
logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys()
) | {
"ident",
"message",
"asctime",
"right",
}
class AutoFormatter(logging.Formatter):
"""
Automatically sets up the formatter based on the environment.
It will switch between the Debug and Production formatters based upon
how the environment is set up. Additionally, it will automatically
detect if the output is a TTY and colorize the output accordingly.
"""
SETUP = False
ATTY = is_atty()
NO_COLOR = os.environ.get("SANIC_NO_COLOR", "false").lower() == "true"
LOG_EXTRA = os.environ.get("SANIC_LOG_EXTRA", "true").lower() == "true"
IDENT = os.environ.get("SANIC_WORKER_IDENTIFIER", "Main ") or "Main "
DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"
IDENT_LIMIT = 5
MESSAGE_START = 42
PREFIX_FORMAT = (
f"{c.GREY}%(ident)s{{limit}} %(asctime)s {c.END}"
"%(levelname)s: {start}"
)
MESSAGE_FORMAT = "%(message)s"
def __init__(self, *args) -> None:
args_list = list(args)
if not args:
args_list.append(self._make_format())
elif args and not args[0]:
args_list[0] = self._make_format()
if len(args_list) < 2:
args_list.append(self.DATE_FORMAT)
elif not args[1]:
args_list[1] = self.DATE_FORMAT
super().__init__(*args_list)
def format(self, record: logging.LogRecord) -> str:
record.ident = self.IDENT
self._set_levelname(record)
output = super().format(record)
if self.LOG_EXTRA:
output += self._log_extra(record)
return output
def _set_levelname(self, record: logging.LogRecord) -> None:
if (
self.ATTY
and not self.NO_COLOR
and (color := LEVEL_COLORS.get(record.levelno))
):
record.levelname = f"{color}{record.levelname}{c.END}"
def _make_format(self) -> str:
limit = CONTROL_LIMIT_IDENT.format(limit=self.IDENT_LIMIT)
start = CONTROL_LIMIT_START.format(start=self.MESSAGE_START)
base_format = self.PREFIX_FORMAT + self.MESSAGE_FORMAT
fmt = base_format.format(limit=limit, start=start)
if not self.ATTY or self.NO_COLOR:
return CONTROL_RE.sub("", fmt)
return fmt
def _log_extra(self, record: logging.LogRecord, indent: int = 0) -> str:
extra_lines = [""]
for key, value in record.__dict__.items():
if key not in DEFAULT_FIELDS:
extra_lines.append(self._format_key_value(key, value, indent))
return "\n".join(extra_lines)
def _format_key_value(self, key, value, indent):
indentation = " " * indent
template = (
f"{indentation} {{c.YELLOW}}{{key}}{{c.END}}={{value}}"
if self.ATTY and not self.NO_COLOR
else f"{indentation}{{key}}={{value}}"
)
if isinstance(value, dict):
nested_lines = [template.format(c=c, key=key, value="")]
for nested_key, nested_value in value.items():
nested_lines.append(
self._format_key_value(
nested_key, nested_value, indent + 2
)
)
return "\n".join(nested_lines)
else:
return template.format(c=c, key=key, value=value)
class DebugFormatter(AutoFormatter):
"""
The DebugFormatter is used for development and debugging purposes.
It can be used directly, or it will be automatically selected if the
environment is set up for development and is using the AutoFormatter.
"""
IDENT_LIMIT = 5
MESSAGE_START = 23
DATE_FORMAT = "%H:%M:%S"
def _set_levelname(self, record: logging.LogRecord) -> None:
if len(record.levelname) > 5:
record.levelname = record.levelname[:4]
super()._set_levelname(record)
def formatException(self, ei): # no cov
orig = super().formatException(ei)
if not self.ATTY or self.NO_COLOR:
return orig
colored_traceback = []
lines = orig.splitlines()
for idx, line in enumerate(lines):
if line.startswith(" File"):
line = self._color_file_line(line)
elif line.startswith(" "):
line = self._color_code_line(line)
elif (
"Error" in line or "Exception" in line or len(lines) - 1 == idx
):
line = self._color_exception_line(line)
colored_traceback.append(line)
return "\n".join(colored_traceback)
def _color_exception_line(self, line: str) -> str: # no cov
match = EXCEPTION_LINE_RE.match(line)
if not match:
return line
exc = match.group("exc")
message = match.group("message")
return f"{c.SANIC}{c.BOLD}{exc}{c.END}: {c.BOLD}{message}{c.END}"
def _color_file_line(self, line: str) -> str: # no cov
match = FILE_LINE_RE.search(line)
if not match:
return line
path = match.group("path")
line_num = match.group("line_num")
location = match.group("location")
return (
f' File "{path}", line {c.CYAN}{c.BOLD}{line_num}{c.END}, '
f"in {c.BLUE}{c.BOLD}{location}{c.END}"
)
def _color_code_line(self, line: str) -> str: # no cov
return f"{c.YELLOW}{line}{c.END}"
class ProdFormatter(AutoFormatter):
"""
The ProdFormatter is used for production environments.
It can be used directly, or it will be automatically selected if the
environment is set up for production and is using the AutoFormatter.
"""
class LegacyFormatter(AutoFormatter):
"""
The LegacyFormatter is used if you want to use the old style of logging.
You can use it as follows, typically in conjunction with the
LegacyAccessFormatter:
.. code-block:: python
from sanic.log import LOGGING_CONFIG_DEFAULTS
LOGGING_CONFIG_DEFAULTS["formatters"] = {
"generic": {
"class": "sanic.logging.formatter.LegacyFormatter"
},
"access": {
"class": "sanic.logging.formatter.LegacyAccessFormatter"
},
}
"""
PREFIX_FORMAT = "%(asctime)s [%(process)s] [%(levelname)s] "
DATE_FORMAT = "[%Y-%m-%d %H:%M:%S %z]"
class AutoAccessFormatter(AutoFormatter):
MESSAGE_FORMAT = (
f"{c.PURPLE}%(host)s "
f"{c.BLUE + c.BOLD}%(request)s{c.END} "
f"%(right)s%(status)s %(byte)s {c.GREY}%(duration)s{c.END}"
)
def format(self, record: logging.LogRecord) -> str:
status = len(str(getattr(record, "status", "")))
byte = len(str(getattr(record, "byte", "")))
duration = len(str(getattr(record, "duration", "")))
record.right = (
CONTROL_LIMIT_END.format(right=status + byte + duration + 1)
if self.ATTY
else ""
)
return super().format(record)
def _set_levelname(self, record: logging.LogRecord) -> None:
if self.ATTY and record.levelno == logging.INFO:
record.levelname = f"{c.SANIC}ACCESS{c.END}"
class LegacyAccessFormatter(AutoAccessFormatter):
"""
The LegacyFormatter is used if you want to use the old style of logging.
You can use it as follows, typically in conjunction with the
LegacyFormatter:
.. code-block:: python
from sanic.log import LOGGING_CONFIG_DEFAULTS
LOGGING_CONFIG_DEFAULTS["formatters"] = {
"generic": {
"class": "sanic.logging.formatter.LegacyFormatter"
},
"access": {
"class": "sanic.logging.formatter.LegacyAccessFormatter"
},
}
"""
PREFIX_FORMAT = "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: "
MESSAGE_FORMAT = "%(request)s %(message)s %(status)s %(byte)s"
class DebugAccessFormatter(AutoAccessFormatter):
IDENT_LIMIT = 5
MESSAGE_START = 23
DATE_FORMAT = "%H:%M:%S"
LOG_EXTRA = False
class ProdAccessFormatter(AutoAccessFormatter):
IDENT_LIMIT = 5
MESSAGE_START = 42
PREFIX_FORMAT = (
f"{c.GREY}%(ident)s{{limit}}|%(asctime)s{c.END} "
f"%(levelname)s: {{start}}"
)
MESSAGE_FORMAT = (
f"{c.PURPLE}%(host)s {c.BLUE + c.BOLD}"
f"%(request)s{c.END} "
f"%(right)s%(status)s %(byte)s {c.GREY}%(duration)s{c.END}"
)
LOG_EXTRA = False
class JSONFormatter(AutoFormatter):
"""
The JSONFormatter is used to output logs in JSON format.
This is useful for logging to a file or to a log aggregator that
understands JSON. It will output all the fields from the LogRecord
as well as the extra fields that are passed in.
You can use it as follows:
.. code-block:: python
from sanic.log import LOGGING_CONFIG_DEFAULTS
LOGGING_CONFIG_DEFAULTS["formatters"] = {
"generic": {
"class": "sanic.logging.formatter.JSONFormatter"
},
"access": {
"class": "sanic.logging.formatter.JSONFormatter"
},
}
"""
ATTY = False
NO_COLOR = True
FIELDS = [
"name",
"levelno",
"pathname",
"module",
"filename",
"lineno",
]
dumps = json_dumps
def format(self, record: logging.LogRecord) -> str:
return self.format_dict(self.to_dict(record))
def to_dict(self, record: logging.LogRecord) -> dict:
base = {field: getattr(record, field, None) for field in self.FIELDS}
extra = {
key: value
for key, value in record.__dict__.items()
if key not in DEFAULT_FIELDS
}
info = {}
if record.exc_info:
info["exc_info"] = self.formatException(record.exc_info)
if record.stack_info:
info["stack_info"] = self.formatStack(record.stack_info)
return {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"message": record.getMessage(),
**base,
**info,
**extra,
}
def format_dict(self, record: dict) -> str:
return self.dumps(record)
class JSONAccessFormatter(JSONFormatter):
"""
The JSONAccessFormatter is used to output access logs in JSON format.
This is useful for logging to a file or to a log aggregator that
understands JSON. It will output all the fields from the LogRecord
as well as the extra fields that are passed in.
You can use it as follows:
.. code-block:: python
from sanic.log import LOGGING_CONFIG_DEFAULTS
LOGGING_CONFIG_DEFAULTS["formatters"] = {
"generic": {
"class": "sanic.logging.formatter.JSONFormatter"
},
"access": {
"class": "sanic.logging.formatter.JSONAccessFormatter"
},
}
"""
FIELDS = [
"host",
"request",
"status",
"byte",
"duration",
]
def to_dict(self, record: logging.LogRecord) -> dict:
base = {field: getattr(record, field, None) for field in self.FIELDS}
return {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"message": record.getMessage(),
**base,
}
@@ -0,0 +1,37 @@
import logging
from sanic.logging.filter import VerbosityFilter
_verbosity_filter = VerbosityFilter()
logger = logging.getLogger("sanic.root") # no cov
"""
General Sanic logger
"""
logger.addFilter(_verbosity_filter)
error_logger = logging.getLogger("sanic.error") # no cov
"""
Logger used by Sanic for error logging
"""
error_logger.addFilter(_verbosity_filter)
access_logger = logging.getLogger("sanic.access") # no cov
"""
Logger used by Sanic for access logging
"""
access_logger.addFilter(_verbosity_filter)
server_logger = logging.getLogger("sanic.server") # no cov
"""
Logger used by Sanic for server related messages
"""
server_logger.addFilter(_verbosity_filter)
websockets_logger = logging.getLogger("sanic.websockets") # no cov
"""
Logger used by Sanic for websockets module and protocol related messages
"""
websockets_logger.addFilter(_verbosity_filter)
websockets_logger.setLevel(logging.WARNING) # Too noisy on debug/info
@@ -0,0 +1,61 @@
import logging
import os
from sanic.helpers import Default, _default
from sanic.log import (
access_logger,
error_logger,
logger,
server_logger,
websockets_logger,
)
from sanic.logging.formatter import (
AutoAccessFormatter,
AutoFormatter,
DebugAccessFormatter,
DebugFormatter,
ProdAccessFormatter,
ProdFormatter,
)
def setup_logging(
debug: bool,
no_color: bool = False,
log_extra: bool | Default = _default,
) -> None:
if AutoFormatter.SETUP:
return
if isinstance(log_extra, Default):
log_extra = debug
os.environ["SANIC_LOG_EXTRA"] = str(log_extra)
AutoFormatter.LOG_EXTRA = log_extra
if no_color:
os.environ["SANIC_NO_COLOR"] = str(no_color)
AutoFormatter.NO_COLOR = no_color
AutoFormatter.SETUP = True
for lggr in (logger, server_logger, error_logger, websockets_logger):
_auto_format(
lggr,
AutoFormatter,
DebugFormatter if debug else ProdFormatter,
)
_auto_format(
access_logger,
AutoAccessFormatter,
DebugAccessFormatter if debug else ProdAccessFormatter,
)
def _auto_format(
logger: logging.Logger,
auto_class: type[AutoFormatter],
formatter_class: type[AutoFormatter],
) -> None:
for handler in logger.handlers:
if type(handler.formatter) is auto_class:
handler.setFormatter(formatter_class())
@@ -0,0 +1,105 @@
from __future__ import annotations
from collections import deque
from collections.abc import Sequence
from enum import IntEnum, auto
from itertools import count
from sanic.models.handler_types import MiddlewareType
class MiddlewareLocation(IntEnum):
REQUEST = auto()
RESPONSE = auto()
class Middleware:
"""Middleware object that is used to encapsulate middleware functions.
This should generally not be instantiated directly, but rather through
the `sanic.Sanic.middleware` decorator and its variants.
Args:
func (MiddlewareType): The middleware function to be called.
location (MiddlewareLocation): The location of the middleware.
priority (int): The priority of the middleware.
"""
_counter = count()
count: int
__slots__ = ("func", "priority", "location", "definition")
def __init__(
self,
func: MiddlewareType,
location: MiddlewareLocation,
priority: int = 0,
) -> None:
self.func = func
self.priority = priority
self.location = location
self.definition = next(Middleware._counter)
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __hash__(self) -> int:
return hash(self.func)
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}("
f"func=<function {self.func.__name__}>, "
f"priority={self.priority}, "
f"location={self.location.name})"
)
@property
def order(self) -> tuple[int, int]:
"""Return a tuple of the priority and definition order.
This is used to sort the middleware.
Returns:
tuple[int, int]: The priority and definition order.
"""
return (self.priority, -self.definition)
@classmethod
def convert(
cls,
*middleware_collections: Sequence[Middleware | MiddlewareType],
location: MiddlewareLocation,
) -> deque[Middleware]:
"""Convert middleware collections to a deque of Middleware objects.
Args:
*middleware_collections (Sequence[Union[Middleware, MiddlewareType]]):
The middleware collections to convert.
location (MiddlewareLocation): The location of the middleware.
Returns:
Deque[Middleware]: The converted middleware.
""" # noqa: E501
return deque(
[
middleware
if isinstance(middleware, Middleware)
else Middleware(middleware, location)
for collection in middleware_collections
for middleware in collection
]
)
@classmethod
def reset_count(cls) -> None:
"""Reset the counter for the middleware definition order.
This is used for testing.
Returns:
None
"""
cls._counter = count()
cls.count = next(cls._counter)
@@ -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,
)
@@ -0,0 +1,97 @@
import asyncio
from collections.abc import Awaitable, MutableMapping
from typing import Any, Callable
from sanic.exceptions import BadRequest
from sanic.models.protocol_types import TransportProtocol
from sanic.server.websockets.connection import WebSocketConnection
ASGIScope = MutableMapping[str, Any]
ASGIMessage = MutableMapping[str, Any]
ASGISend = Callable[[ASGIMessage], Awaitable[None]]
ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
class MockProtocol: # no cov
def __init__(self, transport: "MockTransport", loop):
self.transport = transport
self._not_paused = asyncio.Event()
self._not_paused.set()
self._complete = asyncio.Event()
def pause_writing(self) -> None:
self._not_paused.clear()
def resume_writing(self) -> None:
self._not_paused.set()
async def complete(self) -> None:
self._not_paused.set()
await self.transport.send(
{"type": "http.response.body", "body": b"", "more_body": False}
)
@property
def is_complete(self) -> bool:
return self._complete.is_set()
async def push_data(self, data: bytes) -> None:
if not self.is_complete:
await self.transport.send(
{"type": "http.response.body", "body": data, "more_body": True}
)
async def drain(self) -> None:
await self._not_paused.wait()
class MockTransport(TransportProtocol): # no cov
_protocol: MockProtocol | None
def __init__(
self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
) -> None:
self.scope = scope
self._receive = receive
self._send = send
self._protocol = None
self.loop: asyncio.AbstractEventLoop | None = None
def get_protocol(self) -> MockProtocol: # type: ignore
if not self._protocol:
self._protocol = MockProtocol(self, self.loop)
return self._protocol
def get_extra_info(self, info: str, default=None) -> str | bool | None:
if info == "peername":
return self.scope.get("client")
elif info == "sslcontext":
return self.scope.get("scheme") in ["https", "wss"]
return default
def get_websocket_connection(self) -> WebSocketConnection:
try:
return self._websocket_connection
except AttributeError:
raise BadRequest("Improper websocket connection.")
def create_websocket_connection(
self, send: ASGISend, receive: ASGIReceive
) -> WebSocketConnection:
self._websocket_connection = WebSocketConnection(
send, receive, self.scope.get("subprotocols", [])
)
return self._websocket_connection
def add_task(self) -> None:
raise NotImplementedError
async def send(self, data) -> None:
# TODO:
# - Validation on data and that it is formatted properly and is valid
await self._send(data)
async def receive(self) -> ASGIMessage:
return await self._receive()
@@ -0,0 +1,69 @@
from typing import Any, NamedTuple
class REPLLocal(NamedTuple):
var: Any
name: str
desc: str
class REPLContext:
BUILTINS = {
"app": "The Sanic application instance",
"sanic": "The Sanic module",
"do": "An async function to fake a request to the application",
"client": "A client to access the Sanic app instance using httpx",
}
def __init__(self):
self._locals: set[REPLLocal] = set()
def add(
self,
var: Any,
name: str | None = None,
desc: str | None = None,
):
"""Add a local variable to be available in REPL context.
Args:
var (Any): A module, class, object or a class.
name (Optional[str], optional): An alias for the local. Defaults to None.
desc (Optional[str], optional): A brief description for the local. Defaults to None.
""" # noqa: E501
if name is None:
try:
name = var.__name__
except AttributeError:
name = var.__class__.__name__
if desc is None:
try:
desc = var.__doc__ or ""
except AttributeError:
desc = str(type(var))
assert isinstance(desc, str) and isinstance(
name, str
) # Just to make mypy happy
if name in self.BUILTINS:
raise ValueError(f"Cannot override built-in variable: {name}")
desc = self._truncate(desc)
self._locals.add(REPLLocal(var, name, desc))
def __setattr__(self, name: str, value: Any):
if name.startswith("_"):
super().__setattr__(name, value)
else:
self.add(value, name)
def __iter__(self):
return iter(self._locals)
@staticmethod
def _truncate(s: str, limit: int = 40) -> str:
s = s.replace("\n", " ")
return s[:limit] + "..." if len(s) > limit else s
@@ -0,0 +1,80 @@
from collections.abc import Iterable
from pathlib import Path
from typing import Callable, NamedTuple
from sanic.handlers.directory import DirectoryHandler
from sanic.models.handler_types import (
ErrorMiddlewareType,
ListenerType,
MiddlewareType,
SignalHandler,
)
from sanic.types import HashableDict
class FutureRoute(NamedTuple):
handler: str
uri: str
methods: Iterable[str] | None
host: str | list[str]
strict_slashes: bool
stream: bool
version: int | None
name: str
ignore_body: bool
websocket: bool
subprotocols: list[str] | None
unquote: bool
static: bool
version_prefix: str
error_format: str | None
route_context: HashableDict
class FutureListener(NamedTuple):
listener: ListenerType
event: str
priority: int
class FutureMiddleware(NamedTuple):
middleware: MiddlewareType
attach_to: str
class FutureException(NamedTuple):
handler: ErrorMiddlewareType
exceptions: list[BaseException]
class FutureStatic(NamedTuple):
uri: str
file_or_directory: Path
pattern: str
use_modified_since: bool
use_content_range: bool
stream_large_files: bool | int
name: str
host: str | None
strict_slashes: bool | None
content_type: str | None
resource_type: str | None
directory_handler: DirectoryHandler
follow_external_symlink_files: bool
follow_external_symlink_dirs: bool
class FutureSignal(NamedTuple):
handler: SignalHandler
event: str
condition: dict[str, str] | None
exclusive: bool
priority: int
class FutureRegistry(set): ...
class FutureCommand(NamedTuple):
name: str
func: Callable
@@ -0,0 +1,30 @@
from asyncio.events import AbstractEventLoop
from collections.abc import Coroutine
from typing import Any, Callable, TypeVar
import sanic
from sanic import request
from sanic.response import BaseHTTPResponse, HTTPResponse
Sanic = TypeVar("Sanic", bound="sanic.Sanic")
Request = TypeVar("Request", bound="request.Request")
MiddlewareResponse = (
HTTPResponse | None | Coroutine[Any, Any, HTTPResponse | None]
)
RequestMiddlewareType = Callable[[Request], MiddlewareResponse]
ResponseMiddlewareType = Callable[
[Request, BaseHTTPResponse], MiddlewareResponse
]
ErrorMiddlewareType = Callable[
[Request, BaseException], Coroutine[Any, Any, None] | None
]
MiddlewareType = RequestMiddlewareType | ResponseMiddlewareType
ListenerType = (
Callable[[Sanic], Coroutine[Any, Any, None] | None]
| Callable[[Sanic, AbstractEventLoop], Coroutine[Any, Any, None] | None]
)
RouteHandler = Callable[..., Coroutine[Any, Any, HTTPResponse | None]]
SignalHandler = Callable[..., Coroutine[Any, Any, None]]
@@ -0,0 +1,34 @@
from __future__ import annotations
from base64 import b64decode
from dataclasses import dataclass, field
@dataclass()
class Credentials:
auth_type: str | None
token: str | None
_username: str | None = field(default=None)
_password: str | None = field(default=None)
def __post_init__(self):
if self._auth_is_basic:
self._username, self._password = (
b64decode(self.token.encode("utf-8")).decode().split(":")
)
@property
def username(self):
if not self._auth_is_basic:
raise AttributeError("Username is available for Basic Auth only")
return self._username
@property
def password(self):
if not self._auth_is_basic:
raise AttributeError("Password is available for Basic Auth only")
return self._password
@property
def _auth_is_basic(self) -> bool:
return self.auth_type == "Basic"
@@ -0,0 +1,29 @@
from __future__ import annotations
from asyncio import BaseTransport
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from sanic.http.constants import HTTP
from sanic.models.asgi import ASGIScope
class HTMLProtocol(Protocol):
def __html__(self) -> str | bytes: ...
def _repr_html_(self) -> str | bytes: ...
class Range(Protocol):
start: int | None
end: int | None
size: int | None
total: int | None
__slots__ = ()
class TransportProtocol(BaseTransport):
scope: ASGIScope
version: HTTP
__slots__ = ()
@@ -0,0 +1,71 @@
from __future__ import annotations
from ssl import SSLContext, SSLObject
from types import SimpleNamespace
from typing import Any
from sanic.models.protocol_types import TransportProtocol
class Signal:
stopped = False
class ConnInfo:
"""
Local and remote addresses and SSL status info.
"""
__slots__ = (
"client_port",
"client",
"client_ip",
"ctx",
"lost",
"peername",
"server_port",
"server",
"server_name",
"sockname",
"ssl",
"cert",
"network_paths",
)
def __init__(self, transport: TransportProtocol, unix=None):
self.ctx = SimpleNamespace()
self.lost = False
self.peername: tuple[str, int] | None = None
self.server = self.client = ""
self.server_port = self.client_port = 0
self.client_ip = ""
self.sockname = addr = transport.get_extra_info("sockname")
self.ssl = False
self.server_name = ""
self.cert: dict[str, Any] = {}
self.network_paths: list[Any] = []
sslobj: SSLObject | None = transport.get_extra_info("ssl_object") # type: ignore
sslctx: SSLContext | None = transport.get_extra_info("ssl_context") # type: ignore
if sslobj:
self.ssl = True
self.server_name = getattr(sslobj, "sanic_server_name", None) or ""
self.cert = dict(getattr(sslobj.context, "sanic", {}))
if sslctx and not self.cert:
self.cert = dict(getattr(sslctx, "sanic", {}))
if isinstance(addr, str): # UNIX socket
self.server = unix or addr
return
# IPv4 (ip, port) or IPv6 (ip, port, flowinfo, scopeid)
if isinstance(addr, tuple):
self.server = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
self.server_port = addr[1]
# self.server gets non-standard port appended
if addr[1] != (443 if self.ssl else 80):
self.server = f"{self.server}:{addr[1]}"
self.peername = addr = transport.get_extra_info("peername")
self.network_paths = transport.get_extra_info("network_paths") # type: ignore
if isinstance(addr, tuple):
self.client = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
self.client_ip = addr[0]
self.client_port = addr[1]
@@ -0,0 +1,81 @@
from abc import ABC, abstractmethod
from html5tagger import HTML, Builder, Document
from sanic import __version__ as VERSION
from sanic.application.logo import SVG_LOGO_SIMPLE
from sanic.pages.css import CSS
class BasePage(ABC, metaclass=CSS): # no cov
"""Base page for Sanic pages."""
TITLE = "Sanic"
HEADING = None
CSS: str
doc: Builder
def __init__(self, debug: bool = True) -> None:
self.debug = debug
@property
def style(self) -> str:
"""Returns the CSS for the page.
Returns:
str: The CSS for the page.
"""
return self.CSS
def render(self) -> str:
"""Renders the page.
Returns:
str: The rendered page.
"""
self.doc = Document(self.TITLE, lang="en", id="sanic")
self._head()
self._body()
self._foot()
return str(self.doc)
def _head(self) -> None:
self.doc.style(HTML(self.style))
with self.doc.header:
self.doc.div(self.HEADING or self.TITLE)
def _foot(self) -> None:
with self.doc.footer:
self.doc.div("powered by")
with self.doc.div:
self._sanic_logo()
if self.debug:
self.doc.div(f"Version {VERSION}")
with self.doc.div:
for idx, (title, href) in enumerate(
(
("Docs", "https://sanic.dev"),
("Help", "https://sanic.dev/en/help.html"),
("GitHub", "https://github.com/sanic-org/sanic"),
)
):
if idx > 0:
self.doc(" | ")
self.doc.a(
title,
href=href,
target="_blank",
referrerpolicy="no-referrer",
)
self.doc.div("DEBUG mode")
@abstractmethod
def _body(self) -> None: ...
def _sanic_logo(self) -> None:
self.doc.a(
HTML(SVG_LOGO_SIMPLE),
href="https://sanic.dev",
target="_blank",
referrerpolicy="no-referrer",
)
@@ -0,0 +1,34 @@
from abc import ABCMeta
from pathlib import Path
CURRENT_DIR = Path(__file__).parent
def _extract_style(maybe_style: str | None, name: str) -> str:
if maybe_style is not None:
maybe_path = Path(maybe_style)
if maybe_path.exists():
return maybe_path.read_text(encoding="UTF-8")
return maybe_style
maybe_path = CURRENT_DIR / "styles" / f"{name}.css"
if maybe_path.exists():
return maybe_path.read_text(encoding="UTF-8")
return ""
class CSS(ABCMeta):
"""Cascade stylesheets, i.e. combine all ancestor styles"""
def __new__(cls, name, bases, attrs):
Page = super().__new__(cls, name, bases, attrs)
# Use a locally defined STYLE or the one from styles directory
Page.STYLE = _extract_style(attrs.get("STYLE_FILE"), name)
Page.STYLE += attrs.get("STYLE_APPEND", "")
# Combine with all ancestor styles
Page.CSS = "".join(
Class.STYLE
for Class in reversed(Page.__mro__)
if type(Class) is CSS
)
return Page
@@ -0,0 +1,63 @@
from collections.abc import Iterable
from typing import TypedDict
from html5tagger import E
from .base import BasePage
class FileInfo(TypedDict):
"""Type for file info."""
icon: str
file_name: str
file_access: str
file_size: str
class DirectoryPage(BasePage): # no cov
"""Page for viewing a directory."""
TITLE = "Directory Viewer"
def __init__(
self, files: Iterable[FileInfo], url: str, debug: bool
) -> None:
super().__init__(debug)
self.files = files
self.url = url
def _body(self) -> None:
with self.doc.main:
self._headline()
files = list(self.files)
if files:
self._file_table(files)
else:
self.doc.p("The folder is empty.")
def _headline(self):
"""Implement a heading with the current path, combined with
breadcrumb links"""
with self.doc.h1(id="breadcrumbs"):
p = self.url.split("/")[:-1]
for i, part in enumerate(p):
path = "/".join(p[: i + 1]) + "/"
with self.doc.a(href=path):
self.doc.span(part, class_="dir").span("/", class_="sep")
def _file_table(self, files: Iterable[FileInfo]):
with self.doc.table(class_="autoindex container"):
for f in files:
self._file_row(**f)
def _file_row(
self,
icon: str,
file_name: str,
file_access: str,
file_size: str,
):
first = E.span(icon, class_="icon").a(file_name, href=file_name)
self.doc.tr.td(first).td(file_size).td(file_access)
@@ -0,0 +1,112 @@
from collections.abc import Mapping
from typing import Any
import tracerite.html
from html5tagger import E
from tracerite import html_traceback, inspector
from sanic.request import Request
from .base import BasePage
# Avoid showing the request in the traceback variable inspectors
inspector.blacklist_types += (Request,)
ENDUSER_TEXT = """\
We're sorry, but it looks like something went wrong. Please try refreshing \
the page or navigating back to the homepage. If the issue persists, our \
technical team is working to resolve it as soon as possible. We apologize \
for the inconvenience and appreciate your patience.\
"""
class ErrorPage(BasePage):
"""Page for displaying an error."""
STYLE_APPEND = tracerite.html.style
def __init__(
self,
debug: bool,
title: str,
text: str,
request: Request,
exc: Exception,
) -> None:
super().__init__(debug)
name = request.app.name.replace("_", " ").strip()
if name.islower():
name = name.title()
self.TITLE = f"Application {name} cannot handle your request"
self.HEADING = E("Application ").strong(name)(
" cannot handle your request"
)
self.title = title
self.text = text
self.request = request
self.exc = exc
self.details_open = not getattr(exc, "quiet", False)
def _head(self) -> None:
self.doc._script(tracerite.html.javascript)
super()._head()
def _body(self) -> None:
debug = self.request.app.debug
route_name = self.request.name or "[route not found]"
with self.doc.main:
self.doc.h1(f"⚠️ {self.title}").p(self.text)
# Show context details if available on the exception
context = getattr(self.exc, "context", None)
if context:
self._key_value_table(
"Issue context", "exception-context", context
)
if not debug:
with self.doc.div(id="enduser"):
self.doc.p(ENDUSER_TEXT).p.a("Front Page", href="/")
return
# Show additional details in debug mode,
# open by default for 500 errors
with self.doc.details(open=self.details_open, class_="smalltext"):
# Show extra details if available on the exception
extra = getattr(self.exc, "extra", None)
if extra:
self._key_value_table(
"Issue extra data", "exception-extra", extra
)
self.doc.summary(
"Details for developers (Sanic debug mode only)"
)
if self.exc:
with self.doc.div(class_="exception-wrapper"):
self.doc.h2(f"Exception in {route_name}:")
self.doc(
html_traceback(self.exc, include_js_css=False)
)
self._key_value_table(
f"{self.request.method} {self.request.path}",
"request-headers",
self.request.headers,
)
def _key_value_table(
self, title: str, table_id: str, data: Mapping[str, Any]
) -> None:
with self.doc.div(class_="key-value-display"):
self.doc.h2(title)
with self.doc.dl(id=table_id, class_="key-value-table smalltext"):
for key, value in data.items():
# Reading values may cause a new exception, so suppress it
try:
value = str(value)
except Exception:
value = E.em("Unable to display value")
self.doc.dt.span(key, class_="nobr key").span(": ").dd(
value
)
@@ -0,0 +1,146 @@
/** BasePage **/
:root {
--sanic: #ff0d68;
--sanic-yellow: #FFE900;
--sanic-background: #efeced;
--sanic-text: #121010;
--sanic-text-lighter: #756169;
--sanic-link: #ff0d68;
--sanic-block-background: #f7f4f6;
--sanic-block-text: #000;
--sanic-block-alt-text: #6b6468;
--sanic-header-background: #272325;
--sanic-header-border: #fff;
--sanic-header-text: #fff;
--sanic-highlight-background: var(--sanic-yellow);
--sanic-highlight-text: var(--sanic-text);
--sanic-tab-background: #f7f4f6;
--sanic-tab-shadow: #f7f6f6;
--sanic-tab-text: #222021;
--sanic-tracerite-var: var(--sanic-text);
--sanic-tracerite-val: #ff0d68;
--sanic-tracerite-type: #6d6a6b;
}
@media (prefers-color-scheme: dark) {
:root {
--sanic-text: #f7f4f6;
--sanic-background: #121010;
--sanic-block-background: #0f0d0e;
--sanic-block-text: #f7f4f6;
--sanic-header-background: #030203;
--sanic-header-border: #000;
--sanic-highlight-text: var(--sanic-background);
--sanic-tab-background: #292728;
--sanic-tab-shadow: #0f0d0e;
--sanic-tab-text: #aea7ab;
}
}
html {
font: 16px sans-serif;
background: var(--sanic-background);
color: var(--sanic-text);
scrollbar-gutter: stable;
overflow: hidden auto;
}
body {
margin: 0;
font-size: 1.25rem;
line-height: 125%;
}
body>* {
padding: 1rem 2vw;
}
@media (max-width: 1000px) {
body>* {
padding: 0.5rem 1.5vw;
}
html {
/* Scale everything by rem of 6px-16px by viewport width */
font-size: calc(6px + 10 * 100vw / 1000);
}
}
main {
/* Make sure the footer is closer to bottom */
min-height: 70vh;
/* Generous padding for readability */
padding: 1rem 2.5rem;
}
.smalltext {
font-size: 1.0rem;
}
.container {
min-width: 600px;
max-width: 1600px;
}
header {
background: var(--sanic-header-background);
color: var(--sanic-header-text);
border-bottom: 1px solid var(--sanic-header-border);
text-align: center;
}
footer {
text-align: center;
display: flex;
flex-direction: column;
font-size: 0.8rem;
margin: 2rem;
line-height: 1.5em;
}
h1 {
text-align: left;
}
a {
text-decoration: none;
color: var(--sanic-link);
}
a:hover,
a:focus {
text-decoration: underline;
outline: none;
}
span.icon {
margin-right: 1rem;
}
#logo-simple {
height: 1.75rem;
padding: 0 0.25rem;
}
@media (prefers-color-scheme: dark) {
#logo-simple path:last-child {
fill: #e1e1e1;
}
}
#sanic pre,
#sanic code {
font-family: "Fira Code",
"Source Code Pro",
Menlo,
Meslo,
Monaco,
Consolas,
Lucida Console,
monospace;
font-size: 0.8rem;
}
@@ -0,0 +1,63 @@
/** DirectoryPage **/
#breadcrumbs>a:hover {
text-decoration: none;
}
#breadcrumbs>a .dir {
padding: 0 0.25em;
}
#breadcrumbs>a:first-child:hover::before,
#breadcrumbs>a .dir:hover {
text-decoration: underline;
}
#breadcrumbs>a:first-child::before {
content: "🏠";
}
#breadcrumbs>a:last-child {
color: #ff0d68;
}
main a {
color: inherit;
font-weight: bold;
}
table.autoindex {
width: 100%;
font-family: monospace;
font-size: 1.25rem;
}
table.autoindex tr {
display: flex;
}
table.autoindex tr:hover {
background-color: #ddd;
}
table.autoindex td {
margin: 0 0.5rem;
}
table.autoindex td:first-child {
flex: 1;
}
table.autoindex td:nth-child(2) {
text-align: right;
}
table.autoindex td:last-child {
text-align: right;
}
@media (prefers-color-scheme: dark) {
table.autoindex tr:hover {
background-color: #222;
}
}
@@ -0,0 +1,108 @@
/** ErrorPage **/
#enduser {
max-width: 30em;
margin: 5em auto 5em auto;
text-align: justify;
/*text-justify: both;*/
}
#enduser a {
color: var(--sanic-blue);
}
#enduser p:last-child {
text-align: right;
}
summary {
margin-top: 3em;
color: var(--sanic-text-lighter);
cursor: pointer;
}
.tracerite {
--tracerite-var: var(--sanic-tracerite-var);
--tracerite-val: var(--sanic-tracerite-val);
--tracerite-type: var(--sanic-tracerite-type);
--tracerite-exception: var(--sanic);
--tracerite-highlight: var(--sanic-yellow);
--tracerite-tab: var(--sanic-tab-background);
--tracerite-tab-text: var(--sanic-tab-text);
}
.tracerite>h3 {
margin: 0.5rem 0 !important;
}
#sanic .tracerite .traceback-labels button {
font-size: 0.8rem;
line-height: 120%;
background: var(--tracerite-tab);
color: var(--tracerite-tab-text);
transition: 0.3s;
cursor: pointer;
}
.tracerite .traceback-labels {
padding-top: 5px;
}
.tracerite .traceback-labels button:hover {
filter: contrast(150%) brightness(120%) drop-shadow(0 -0 2px var(--sanic-tab-shadow));
}
#sanic .tracerite .tracerite-tooltip::before {
bottom: 1.75em;
}
#sanic .tracerite .traceback-details mark span {
background: var(--sanic-highlight-background);
color: var(--sanic-highlight-text);
}
header {
background: var(--sanic-header-background);
}
h2 {
font-size: 1.3rem;
color: var(--sanic-text);
}
.key-value-display,
.exception-wrapper {
padding: 0.5rem;
margin-top: 1rem;
}
.key-value-display {
background-color: var(--sanic-block-background);
color: var(--sanic-block-text);
}
.key-value-display h2 {
margin-bottom: 0.2em;
}
dl.key-value-table {
width: 100%;
margin: 0;
display: grid;
grid-template-columns: 1fr 5fr;
grid-gap: .3em;
white-space: pre-wrap;
}
dl.key-value-table * {
margin: 0;
}
dl.key-value-table dt {
color: var(--sanic-block-alt-text);
word-break: break-word;
}
dl.key-value-table dd {
/* Better breaking for cookies header and such */
word-break: break-all;
}
@@ -0,0 +1,11 @@
from .form import File, parse_multipart_form
from .parameters import RequestParameters
from .types import Request
__all__ = (
"File",
"parse_multipart_form",
"Request",
"RequestParameters",
)
@@ -0,0 +1,114 @@
from __future__ import annotations
import email.utils
import unicodedata
from typing import NamedTuple
from urllib.parse import unquote
from sanic.headers import parse_content_header
from sanic.log import logger
from .parameters import RequestParameters
class File(NamedTuple):
"""Model for defining a file.
It is a `namedtuple`, therefore you can iterate over the object, or
access the parameters by name.
Args:
type (str, optional): The mimetype, defaults to "text/plain".
body (bytes): Bytes of the file.
name (str): The filename.
"""
type: str
body: bytes
name: str
def parse_multipart_form(body, boundary):
"""Parse a request body and returns fields and files
Args:
body (bytes): Bytes request body.
boundary (bytes): Bytes multipart boundary.
Returns:
tuple[RequestParameters, RequestParameters]: A tuple containing fields and files as `RequestParameters`.
""" # noqa: E501
files = {}
fields = {}
form_parts = body.split(boundary)
for form_part in form_parts[1:-1]:
file_name = None
content_type = "text/plain"
content_charset = "utf-8"
field_name = None
line_index = 2
line_end_index = 0
while not line_end_index == -1:
line_end_index = form_part.find(b"\r\n", line_index)
form_line = form_part[line_index:line_end_index].decode("utf-8")
line_index = line_end_index + 2
if not form_line:
break
colon_index = form_line.index(":")
idx = colon_index + 2
form_header_field = form_line[0:colon_index].lower()
form_header_value, form_parameters = parse_content_header(
form_line[idx:]
)
if form_header_field == "content-disposition":
field_name = form_parameters.get("name")
file_name = form_parameters.get("filename")
# non-ASCII filenames in RFC2231, "filename*" format
if file_name is None and form_parameters.get("filename*"):
encoding, _, value = email.utils.decode_rfc2231(
form_parameters["filename*"]
)
file_name = unquote(value, encoding=encoding)
# Normalize to NFC (Apple MacOS/iOS send NFD)
# Notes:
# - No effect for Windows, Linux or Android clients which
# already send NFC
# - Python open() is tricky (creates files in NFC no matter
# which form you use)
if file_name is not None:
file_name = unicodedata.normalize("NFC", file_name)
elif form_header_field == "content-type":
content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8")
if field_name:
post_data = form_part[line_index:-4]
if file_name is None:
value = post_data.decode(content_charset)
if field_name in fields:
fields[field_name].append(value)
else:
fields[field_name] = [value]
else:
form_file = File(
type=content_type, name=file_name, body=post_data
)
if field_name in files:
files[field_name].append(form_file)
else:
files[field_name] = [form_file]
else:
logger.debug(
"Form-data field does not have a 'name' parameter "
"in the Content-Disposition header"
)
return RequestParameters(fields), RequestParameters(files)
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
class RequestParameters(dict):
"""Hosts a dict with lists as values where get returns the first value of the list and getlist returns the whole shebang""" # noqa: E501
def get(self, name: str, default: Any | None = None) -> Any | None:
"""Return the first value, either the default or actual
Args:
name (str): The name of the parameter
default (Any | None, optional): The default value. Defaults to None.
Returns:
Any | None: The first value of the list
""" # noqa: E501
return super().get(name, [default])[0]
def getlist(
self, name: str, default: list[Any] | None = None
) -> list[Any]:
"""Return the entire list
Args:
name (str): The name of the parameter
default (list[Any] | None, optional): The default value. Defaults to None.
Returns:
list[Any]: The entire list of values or [] if not found
""" # noqa: E501
return super().get(name, default) or []
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
from .convenience import (
empty,
file,
file_stream,
html,
json,
raw,
redirect,
text,
validate_file,
)
from .types import (
BaseHTTPResponse,
HTTPResponse,
JSONResponse,
ResponseStream,
json_dumps,
)
__all__ = (
"BaseHTTPResponse",
"HTTPResponse",
"JSONResponse",
"ResponseStream",
"empty",
"json",
"text",
"raw",
"html",
"validate_file",
"file",
"redirect",
"file_stream",
"json_dumps",
)
@@ -0,0 +1,412 @@
from __future__ import annotations
from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
from mimetypes import guess_type
from os import path
from pathlib import PurePath
from time import time
from typing import Any, AnyStr, Callable
from urllib.parse import quote_plus
from sanic.compat import Header, open_async, stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.helpers import Default, _default
from sanic.log import logger
from sanic.models.protocol_types import HTMLProtocol, Range
from .types import HTTPResponse, JSONResponse, ResponseStream
def empty(
status: int = 204, headers: dict[str, str] | None = None
) -> HTTPResponse:
"""Returns an empty response to the client.
Args:
status (int, optional): HTTP response code. Defaults to `204`.
headers ([type], optional): Custom HTTP headers. Defaults to `None`.
Returns:
HTTPResponse: An empty response to the client.
"""
return HTTPResponse(body=b"", status=status, headers=headers)
def json(
body: Any,
status: int = 200,
headers: dict[str, str] | None = None,
content_type: str = "application/json",
dumps: Callable[..., AnyStr] | None = None,
**kwargs: Any,
) -> JSONResponse:
"""Returns response object with body in json format.
Args:
body (Any): Response data to be serialized.
status (int, optional): HTTP response code. Defaults to `200`.
headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`.
content_type (str, optional): The content type (string) of the response. Defaults to `"application/json"`.
dumps (Callable[..., AnyStr], optional): A custom json dumps function. Defaults to `None`.
**kwargs (Any): Remaining arguments that are passed to the json encoder.
Returns:
JSONResponse: A response object with body in json format.
""" # noqa: E501
return JSONResponse(
body,
status=status,
headers=headers,
content_type=content_type,
dumps=dumps,
**kwargs,
)
def text(
body: str,
status: int = 200,
headers: dict[str, str] | None = None,
content_type: str = "text/plain; charset=utf-8",
) -> HTTPResponse:
"""Returns response object with body in text format.
Args:
body (str): Response data.
status (int, optional): HTTP response code. Defaults to `200`.
headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`.
content_type (str, optional): The content type (string) of the response. Defaults to `"text/plain; charset=utf-8"`.
Returns:
HTTPResponse: A response object with body in text format.
Raises:
TypeError: If the body is not a string.
""" # noqa: E501
if not isinstance(body, str):
raise TypeError(
f"Bad body type. Expected str, got {type(body).__name__})"
)
return HTTPResponse(
body, status=status, headers=headers, content_type=content_type
)
def raw(
body: AnyStr | None,
status: int = 200,
headers: dict[str, str] | None = None,
content_type: str = DEFAULT_HTTP_CONTENT_TYPE,
) -> HTTPResponse:
"""Returns response object without encoding the body.
Args:
body (Optional[AnyStr]): Response data.
status (int, optional): HTTP response code. Defaults to `200`.
headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`.
content_type (str, optional): The content type (string) of the response. Defaults to `"application/octet-stream"`.
Returns:
HTTPResponse: A response object without encoding the body.
""" # noqa: E501
return HTTPResponse(
body=body,
status=status,
headers=headers,
content_type=content_type,
)
def html(
body: str | bytes | HTMLProtocol,
status: int = 200,
headers: dict[str, str] | None = None,
) -> HTTPResponse:
"""Returns response object with body in html format.
Body should be a `str` or `bytes` like object, or an object with `__html__` or `_repr_html_`.
Args:
body (Union[str, bytes, HTMLProtocol]): Response data.
status (int, optional): HTTP response code. Defaults to `200`.
headers (Dict[str, str], optional): Custom HTTP headers. Defaults to `None`.
Returns:
HTTPResponse: A response object with body in html format.
""" # noqa: E501
if not isinstance(body, (str, bytes)):
if hasattr(body, "__html__"):
body = body.__html__()
elif hasattr(body, "_repr_html_"):
body = body._repr_html_()
return HTTPResponse(
body,
status=status,
headers=headers,
content_type="text/html; charset=utf-8",
)
async def validate_file(
request_headers: Header, last_modified: datetime | float | int
) -> HTTPResponse | None:
"""Validate file based on request headers.
Args:
request_headers (Header): The request headers.
last_modified (Union[datetime, float, int]): The last modified date and time of the file.
Returns:
Optional[HTTPResponse]: A response object with status 304 if the file is not modified.
""" # noqa: E501
try:
if_modified_since = request_headers.getone("If-Modified-Since")
except KeyError:
return None
try:
if_modified_since = parsedate_to_datetime(if_modified_since)
except (TypeError, ValueError):
logger.warning(
"Ignorning invalid If-Modified-Since header received: '%s'",
if_modified_since,
)
return None
if not isinstance(last_modified, datetime):
last_modified = datetime.fromtimestamp(
float(last_modified), tz=timezone.utc
).replace(microsecond=0)
if (
last_modified.utcoffset() is None
and if_modified_since.utcoffset() is not None
):
logger.warning(
"Cannot compare tz-aware and tz-naive datetimes. To avoid "
"this conflict Sanic is converting last_modified to UTC."
)
last_modified.replace(tzinfo=timezone.utc)
elif (
last_modified.utcoffset() is not None
and if_modified_since.utcoffset() is None
):
logger.warning(
"Cannot compare tz-aware and tz-naive datetimes. To avoid "
"this conflict Sanic is converting if_modified_since to UTC."
)
if_modified_since.replace(tzinfo=timezone.utc)
if last_modified.timestamp() <= if_modified_since.timestamp():
return HTTPResponse(status=304)
return None
async def file(
location: str | PurePath,
status: int = 200,
request_headers: Header | None = None,
validate_when_requested: bool = True,
mime_type: str | None = None,
headers: dict[str, str] | None = None,
filename: str | None = None,
last_modified: datetime | float | int | Default | None = _default,
max_age: float | int | None = None,
no_store: bool | None = None,
_range: Range | None = None,
) -> HTTPResponse:
"""Return a response object with file data.
Args:
location (Union[str, PurePath]): Location of file on system.
status (int, optional): HTTP response code. Won't enforce the passed in status if only a part of the content will be sent (206) or file is being validated (304). Defaults to 200.
request_headers (Optional[Header], optional): The request headers.
validate_when_requested (bool, optional): If `True`, will validate the file when requested. Defaults to True.
mime_type (Optional[str], optional): Specific mime_type.
headers (Optional[Dict[str, str]], optional): Custom Headers.
filename (Optional[str], optional): Override filename.
last_modified (Optional[Union[datetime, float, int, Default]], optional): The last modified date and time of the file.
max_age (Optional[Union[float, int]], optional): Max age for cache control.
no_store (Optional[bool], optional): Any cache should not store this response. Defaults to None.
_range (Optional[Range], optional):
Returns:
HTTPResponse: The response object with the file data.
""" # noqa: E501
if isinstance(last_modified, datetime):
last_modified = last_modified.replace(microsecond=0).timestamp()
elif isinstance(last_modified, Default):
stat = await stat_async(location)
last_modified = stat.st_mtime
if (
validate_when_requested
and request_headers is not None
and last_modified
):
response = await validate_file(request_headers, last_modified)
if response:
return response
headers = headers or {}
if last_modified:
headers.setdefault(
"Last-Modified", formatdate(last_modified, usegmt=True)
)
if filename:
headers.setdefault(
"Content-Disposition", f'attachment; filename="{filename}"'
)
if no_store:
cache_control = "no-store"
elif max_age:
cache_control = f"public, max-age={max_age}"
headers.setdefault(
"expires",
formatdate(
time() + max_age,
usegmt=True,
),
)
else:
cache_control = "no-cache"
headers.setdefault("cache-control", cache_control)
filename = filename or path.split(location)[-1]
async with await open_async(location, mode="rb") as f:
if _range:
await f.seek(_range.start)
out_stream = await f.read(_range.size)
headers["Content-Range"] = (
f"bytes {_range.start}-{_range.end}/{_range.total}"
)
status = 206
else:
out_stream = await f.read()
content_type = mime_type or guess_content_type(
filename, fallback="text/plain; charset=utf-8"
)
return HTTPResponse(
body=out_stream,
status=status,
headers=headers,
content_type=content_type,
)
def redirect(
to: str,
headers: dict[str, str] | None = None,
status: int = 302,
content_type: str = "text/html; charset=utf-8",
) -> HTTPResponse:
"""Cause a HTTP redirect (302 by default) by setting a Location header.
Args:
to (str): path or fully qualified URL to redirect to
headers (Optional[Dict[str, str]], optional): optional dict of headers to include in the new request. Defaults to None.
status (int, optional): status code (int) of the new request, defaults to 302. Defaults to 302.
content_type (str, optional): the content type (string) of the response. Defaults to "text/html; charset=utf-8".
Returns:
HTTPResponse: A response object with the redirect.
""" # noqa: E501
headers = headers or {}
# URL Quote the URL before redirecting
safe_to = quote_plus(to, safe=":/%#?&=@[]!$&'()*+,;")
# According to RFC 7231, a relative URI is now permitted.
headers["Location"] = safe_to
return HTTPResponse(
status=status, headers=headers, content_type=content_type
)
async def file_stream(
location: str | PurePath,
status: int = 200,
chunk_size: int = 4096,
mime_type: str | None = None,
headers: dict[str, str] | None = None,
filename: str | None = None,
_range: Range | None = None,
) -> ResponseStream:
"""Return a streaming response object with file data.
:param location: Location of file on system.
:param chunk_size: The size of each chunk in the stream (in bytes)
:param mime_type: Specific mime_type.
:param headers: Custom Headers.
:param filename: Override filename.
:param _range:
Args:
location (Union[str, PurePath]): Location of file on system.
status (int, optional): HTTP response code. Won't enforce the passed in status if only a part of the content will be sent (206) or file is being validated (304). Defaults to `200`.
chunk_size (int, optional): The size of each chunk in the stream (in bytes). Defaults to `4096`.
mime_type (Optional[str], optional): Specific mime_type.
headers (Optional[Dict[str, str]], optional): Custom HTTP headers.
filename (Optional[str], optional): Override filename.
_range (Optional[Range], optional): The range of bytes to send.
""" # noqa: E501
headers = headers or {}
if filename:
headers.setdefault(
"Content-Disposition", f'attachment; filename="{filename}"'
)
filename = filename or path.split(location)[-1]
mime_type = mime_type or guess_type(filename)[0] or "text/plain"
if _range:
start = _range.start
end = _range.end
total = _range.total
headers["Content-Range"] = f"bytes {start}-{end}/{total}"
status = 206
async def _streaming_fn(response):
async with await open_async(location, mode="rb") as f:
if _range:
await f.seek(_range.start)
to_send = _range.size
while to_send > 0:
content = await f.read(min((_range.size, chunk_size)))
if len(content) < 1:
break
to_send -= len(content)
await response.write(content)
else:
while True:
content = await f.read(chunk_size)
if len(content) < 1:
break
await response.write(content)
return ResponseStream(
streaming_fn=_streaming_fn,
status=status,
headers=headers,
content_type=mime_type,
)
def guess_content_type(
file_path: str | PurePath,
fallback: str = DEFAULT_HTTP_CONTENT_TYPE,
) -> str:
"""Guess the content type (rather than MIME only) by the file extension."""
mediatype = guess_type(file_path)[0]
if mediatype is None:
return fallback
if mediatype.startswith("text/"):
return f"{mediatype}; charset=utf-8"
return mediatype
@@ -0,0 +1,553 @@
from __future__ import annotations
from collections.abc import Coroutine, Iterator
from datetime import datetime
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
TypeVar,
)
from sanic.compat import Header
from sanic.cookies import CookieJar
from sanic.cookies.response import Cookie, SameSite
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import Default, _default, has_message_body, json_dumps
from sanic.http import Http
if TYPE_CHECKING:
from sanic.asgi import ASGIApp
from sanic.http.http3 import HTTPReceiver
from sanic.request import Request
else:
Request = TypeVar("Request")
class BaseHTTPResponse:
"""The base class for all HTTP Responses"""
__slots__ = (
"asgi",
"body",
"content_type",
"stream",
"status",
"headers",
"_cookies",
)
_dumps = json_dumps
def __init__(self):
self.asgi: bool = False
self.body: bytes | None = None
self.content_type: str | None = None
self.stream: Http | ASGIApp | HTTPReceiver | None = None
self.status: int = None
self.headers = Header({})
self._cookies: CookieJar | None = None
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.status} {self.content_type}>"
def _encode_body(self, data: str | bytes | None):
if data is None:
return b""
return data.encode() if hasattr(data, "encode") else data # type: ignore
@property
def cookies(self) -> CookieJar:
"""The response cookies.
See [Cookies](/en/guide/basics/cookies.html)
Returns:
CookieJar: The response cookies
"""
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self) -> Iterator[tuple[bytes, bytes]]:
"""Obtain a list of header tuples encoded in bytes for sending.
Add and remove headers based on status and content_type.
Returns:
Iterator[Tuple[bytes, bytes]]: A list of header tuples encoded in bytes for sending
""" # noqa: E501
if has_message_body(self.status):
self.headers.setdefault("content-type", self.content_type)
# Encode headers into bytes
return (
(name.encode("ascii"), f"{value}".encode(errors="surrogateescape"))
for name, value in self.headers.items()
)
async def send(
self,
data: AnyStr | None = None,
end_stream: bool | None = None,
) -> None:
"""Send any pending response headers and the given data as body.
Args:
data (Optional[AnyStr], optional): str or bytes to be written. Defaults to `None`.
end_stream (Optional[bool], optional): whether to close the stream after this block. Defaults to `None`.
""" # noqa: E501
if data is None and end_stream is None:
end_stream = True
if self.stream is None:
raise SanicException(
"No stream is connected to the response object instance."
)
if self.stream.send is None:
if end_stream and not data:
return
raise ServerError(
"Response stream was ended, no more response data is "
"allowed to be sent."
)
data = data.encode() if hasattr(data, "encode") else data or b"" # type: ignore
await self.stream.send(
data, # type: ignore
end_stream=end_stream or False,
)
def add_cookie(
self,
key: str,
value: str,
*,
path: str = "/",
domain: str | None = None,
secure: bool = True,
max_age: int | None = None,
expires: datetime | None = None,
httponly: bool = False,
samesite: SameSite | None = "Lax",
partitioned: bool = False,
comment: str | None = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie:
"""Add a cookie to the CookieJar
See [Cookies](/en/guide/basics/cookies.html)
Args:
key (str): The key to be added
value (str): The value to be added
path (str, optional): Path of the cookie. Defaults to `"/"`.
domain (Optional[str], optional): Domain of the cookie. Defaults to `None`.
secure (bool, optional): Whether the cookie is secure. Defaults to `True`.
max_age (Optional[int], optional): Max age of the cookie. Defaults to `None`.
expires (Optional[datetime], optional): Expiry date of the cookie. Defaults to `None`.
httponly (bool, optional): Whether the cookie is http only. Defaults to `False`.
samesite (Optional[SameSite], optional): SameSite policy of the cookie. Defaults to `"Lax"`.
partitioned (bool, optional): Whether the cookie is partitioned. Defaults to `False`.
comment (Optional[str], optional): Comment of the cookie. Defaults to `None`.
host_prefix (bool, optional): Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True. Defaults to `False`.
secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key. This requires that secure=True. Defaults to `False`.
Returns:
Cookie: The cookie that was added
""" # noqa: E501
return self.cookies.add_cookie(
key=key,
value=value,
path=path,
domain=domain,
secure=secure,
max_age=max_age,
expires=expires,
httponly=httponly,
samesite=samesite,
partitioned=partitioned,
comment=comment,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
def delete_cookie(
self,
key: str,
*,
path: str = "/",
domain: str | None = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> None:
"""Delete a cookie
This will effectively set it as Max-Age: 0, which a browser should
interpret it to mean: "delete the cookie".
Since it is a browser/client implementation, your results may vary
depending upon which client is being used.
See [Cookies](/en/guide/basics/cookies.html)
Args:
key (str): The key to be deleted
path (str, optional): Path of the cookie. Defaults to `"/"`.
domain (Optional[str], optional): Domain of the cookie. Defaults to `None`.
host_prefix (bool, optional): Whether to add __Host- as a prefix to the key. This requires that path="/", domain=None, and secure=True. Defaults to `False`.
secure_prefix (bool, optional): Whether to add __Secure- as a prefix to the key. This requires that secure=True. Defaults to `False`.
""" # noqa: E501
self.cookies.delete_cookie(
key=key,
path=path,
domain=domain,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
class HTTPResponse(BaseHTTPResponse):
"""HTTP response to be sent back to the client.
Args:
body (Optional[Any], optional): The body content to be returned. Defaults to `None`.
status (int, optional): HTTP response number. Defaults to `200`.
headers (Optional[Union[Header, Dict[str, str]]], optional): Headers to be returned. Defaults to `None`.
content_type (Optional[str], optional): Content type to be returned (as a header). Defaults to `None`.
""" # noqa: E501
__slots__ = ()
def __init__(
self,
body: Any = None,
status: int = 200,
headers: Header | dict[str, str] | None = None,
content_type: str | None = None,
):
super().__init__()
self.content_type: str | None = content_type
self.body = self._encode_body(body)
self.status = status
self.headers = Header(headers or {})
self._cookies = None
async def eof(self):
"""Send a EOF (End of File) message to the client."""
await self.send("", True)
async def __aenter__(self):
return self.send
async def __aexit__(self, *_):
await self.eof()
class JSONResponse(HTTPResponse):
"""Convenience class for JSON responses
HTTP response to be sent back to the client, when the response
is of json type. Offers several utilities to manipulate common
json data types.
Args:
body (Optional[Any], optional): The body content to be returned. Defaults to `None`.
status (int, optional): HTTP response number. Defaults to `200`.
headers (Optional[Union[Header, Dict[str, str]]], optional): Headers to be returned. Defaults to `None`.
content_type (str, optional): Content type to be returned (as a header). Defaults to `"application/json"`.
dumps (Optional[Callable[..., AnyStr]], optional): The function to use for json encoding. Defaults to `None`.
**kwargs (Any, optional): The kwargs to pass to the json encoding function. Defaults to `{}`.
""" # noqa: E501
__slots__ = (
"_body",
"_body_manually_set",
"_initialized",
"_raw_body",
"_use_dumps",
"_use_dumps_kwargs",
)
def __init__(
self,
body: Any = None,
status: int = 200,
headers: Header | dict[str, str] | None = None,
content_type: str = "application/json",
dumps: Callable[..., AnyStr] | None = None,
**kwargs: Any,
):
self._initialized = False
self._body_manually_set = False
self._use_dumps: Callable[..., str | bytes] = (
dumps or BaseHTTPResponse._dumps
)
self._use_dumps_kwargs = kwargs
self._raw_body = body
super().__init__(
self._encode_body(self._use_dumps(body, **self._use_dumps_kwargs)),
headers=headers,
status=status,
content_type=content_type,
)
self._initialized = True
def _check_body_not_manually_set(self):
if self._body_manually_set:
raise SanicException(
"Cannot use raw_body after body has been manually set."
)
@property
def raw_body(self) -> Any:
"""Returns the raw body, as long as body has not been manually set previously.
NOTE: This object should not be mutated, as it will not be
reflected in the response body. If you need to mutate the
response body, consider using one of the provided methods in
this class or alternatively call set_body() with the mutated
object afterwards or set the raw_body property to it.
Returns:
Optional[Any]: The raw body
""" # noqa: E501
self._check_body_not_manually_set()
return self._raw_body
@raw_body.setter
def raw_body(self, value: Any):
self._body_manually_set = False
self._body = self._encode_body(
self._use_dumps(value, **self._use_dumps_kwargs)
)
self._raw_body = value
@property # type: ignore
def body(self) -> bytes | None: # type: ignore
"""Returns the response body.
Returns:
Optional[bytes]: The response body
"""
return self._body
@body.setter
def body(self, value: bytes | None):
self._body = value
if not self._initialized:
return
self._body_manually_set = True
def set_body(
self,
body: Any,
dumps: Callable[..., AnyStr] | None = None,
**dumps_kwargs: Any,
) -> None:
"""Set the response body to the given value, using the given dumps function
Sets a new response body using the given dumps function
and kwargs, or falling back to the defaults given when
creating the object if none are specified.
Args:
body (Any): The body to set
dumps (Optional[Callable[..., AnyStr]], optional): The function to use for json encoding. Defaults to `None`.
**dumps_kwargs (Any, optional): The kwargs to pass to the json encoding function. Defaults to `{}`.
Examples:
```python
response = JSONResponse({"foo": "bar"})
response.set_body({"bar": "baz"})
assert response.body == b'{"bar": "baz"}'
```
""" # noqa: E501
self._body_manually_set = False
self._raw_body = body
use_dumps = dumps or self._use_dumps
use_dumps_kwargs = dumps_kwargs if dumps else self._use_dumps_kwargs
self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs))
def append(self, value: Any) -> None:
"""Appends a value to the response raw_body, ensuring that body is kept up to date.
This can only be used if raw_body is a list.
Args:
value (Any): The value to append
Raises:
SanicException: If the body is not a list
""" # noqa: E501
self._check_body_not_manually_set()
if not isinstance(self._raw_body, list):
raise SanicException("Cannot append to a non-list object.")
self._raw_body.append(value)
self.raw_body = self._raw_body
def extend(self, value: Any) -> None:
"""Extends the response's raw_body with the given values, ensuring that body is kept up to date.
This can only be used if raw_body is a list.
Args:
value (Any): The values to extend with
Raises:
SanicException: If the body is not a list
""" # noqa: E501
self._check_body_not_manually_set()
if not isinstance(self._raw_body, list):
raise SanicException("Cannot extend a non-list object.")
self._raw_body.extend(value)
self.raw_body = self._raw_body
def update(self, *args, **kwargs) -> None:
"""Updates the response's raw_body with the given values, ensuring that body is kept up to date.
This can only be used if raw_body is a dict.
Args:
*args: The args to update with
**kwargs: The kwargs to update with
Raises:
SanicException: If the body is not a dict
""" # noqa: E501
self._check_body_not_manually_set()
if not isinstance(self._raw_body, dict):
raise SanicException("Cannot update a non-dict object.")
self._raw_body.update(*args, **kwargs)
self.raw_body = self._raw_body
def pop(self, key: Any, default: Any = _default) -> Any:
"""Pops a key from the response's raw_body, ensuring that body is kept up to date.
This can only be used if raw_body is a dict or a list.
Args:
key (Any): The key to pop
default (Any, optional): The default value to return if the key is not found. Defaults to `_default`.
Raises:
SanicException: If the body is not a dict or a list
TypeError: If the body is a list and a default value is provided
Returns:
Any: The value that was popped
""" # noqa: E501
self._check_body_not_manually_set()
if not isinstance(self._raw_body, (list, dict)):
raise SanicException(
"Cannot pop from a non-list and non-dict object."
)
if isinstance(default, Default):
value = self._raw_body.pop(key)
elif isinstance(self._raw_body, list):
raise TypeError("pop doesn't accept a default argument for lists")
else:
value = self._raw_body.pop(key, default)
self.raw_body = self._raw_body
return value
class ResponseStream:
"""A compat layer to bridge the gap after the deprecation of StreamingHTTPResponse.
It will be removed when:
- file_stream is moved to new style streaming
- file and file_stream are combined into a single API
""" # noqa: E501
__slots__ = (
"_cookies",
"content_type",
"headers",
"request",
"response",
"status",
"streaming_fn",
)
def __init__(
self,
streaming_fn: Callable[
[BaseHTTPResponse | ResponseStream],
Coroutine[Any, Any, None],
],
status: int = 200,
headers: Header | dict[str, str] | None = None,
content_type: str | None = None,
):
if headers is None:
headers = Header()
elif not isinstance(headers, Header):
headers = Header(headers)
self.streaming_fn = streaming_fn
self.status = status
self.headers = headers or Header()
self.content_type = content_type
self.request: Request | None = None
self._cookies: CookieJar | None = None
async def write(self, message: str):
await self.response.send(message)
async def stream(self) -> HTTPResponse:
if not self.request:
raise ServerError("Attempted response to unknown request")
self.response = await self.request.respond(
headers=self.headers,
status=self.status,
content_type=self.content_type,
)
await self.streaming_fn(self)
return self.response
async def eof(self) -> None:
await self.response.eof()
@property
def cookies(self) -> CookieJar:
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self):
return self.response.processed_headers
@property
def body(self):
return self.response.body
def __call__(self, request: Request) -> ResponseStream:
self.request = request
return self
def __await__(self):
return self.stream().__await__()
@@ -0,0 +1,273 @@
from __future__ import annotations
from collections.abc import Iterable
from functools import lru_cache
from inspect import signature
from typing import Any
from uuid import UUID
from sanic_routing import BaseRouter
from sanic_routing.exceptions import NoMethod
from sanic_routing.exceptions import NotFound as RoutingNotFound
from sanic_routing.group import RouteGroup
from sanic_routing.route import Route
from sanic.constants import HTTP_METHODS
from sanic.errorpages import check_error_format
from sanic.exceptions import MethodNotAllowed, NotFound, SanicException
from sanic.models.handler_types import RouteHandler
ROUTER_CACHE_SIZE = 1024
ALLOWED_LABELS = ("__file_uri__",)
class Router(BaseRouter):
"""The router implementation responsible for routing a `Request` object to the appropriate handler.""" # noqa: E501
DEFAULT_METHOD = "GET"
ALLOWED_METHODS = HTTP_METHODS
def _get(
self, path: str, method: str, host: str | None
) -> tuple[Route, RouteHandler, dict[str, Any]]:
try:
return self.resolve(
path=path,
method=method,
extra={"host": host} if host else None,
)
except RoutingNotFound as e:
raise NotFound(f"Requested URL {e.path} not found") from None
except NoMethod as e:
raise MethodNotAllowed(
f"Method {method} not allowed for URL {path}",
method=method,
allowed_methods=tuple(e.allowed_methods)
if e.allowed_methods
else None,
) from None
@lru_cache(maxsize=ROUTER_CACHE_SIZE)
def get( # type: ignore
self, path: str, method: str, host: str | None
) -> tuple[Route, RouteHandler, dict[str, Any]]:
"""Retrieve a `Route` object containing the details about how to handle a response for a given request
:param request: the incoming request object
:type request: Request
:return: details needed for handling the request and returning the
correct response
:rtype: Tuple[ Route, RouteHandler, Dict[str, Any]]
Args:
path (str): the path of the route
method (str): the HTTP method of the route
host (Optional[str]): the host of the route
Raises:
NotFound: if the route is not found
MethodNotAllowed: if the method is not allowed for the route
Returns:
Tuple[Route, RouteHandler, Dict[str, Any]]: the route, handler, and match info
""" # noqa: E501
__tracebackhide__ = True
return self._get(path, method, host)
def add( # type: ignore
self,
uri: str,
methods: Iterable[str],
handler: RouteHandler,
host: str | Iterable[str] | None = None,
strict_slashes: bool = False,
stream: bool = False,
ignore_body: bool = False,
version: str | float | int | None = None,
name: str | None = None,
unquote: bool = False,
static: bool = False,
version_prefix: str = "/v",
overwrite: bool = False,
error_format: str | None = None,
) -> Route | list[Route]:
"""Add a handler to the router
Args:
uri (str): The path of the route.
methods (Iterable[str]): The types of HTTP methods that should be attached,
example: ["GET", "POST", "OPTIONS"].
handler (RouteHandler): The sync or async function to be executed.
host (Optional[str], optional): Host that the route should be on. Defaults to None.
strict_slashes (bool, optional): Whether to apply strict slashes. Defaults to False.
stream (bool, optional): Whether to stream the response. Defaults to False.
ignore_body (bool, optional): Whether the incoming request body should be read.
Defaults to False.
version (Union[str, float, int], optional): A version modifier for the uri. Defaults to None.
name (Optional[str], optional): An identifying name of the route. Defaults to None.
Returns:
Route: The route object.
""" # noqa: E501
if version is not None:
version = str(version).strip("/").lstrip("v")
uri = "/".join([f"{version_prefix}{version}", uri.lstrip("/")])
uri = self._normalize(uri, handler)
params = dict(
path=uri,
handler=handler,
methods=frozenset(map(str, methods)) if methods else None,
name=name,
strict=strict_slashes,
unquote=unquote,
overwrite=overwrite,
)
if isinstance(host, str):
hosts = [host]
else:
hosts = host or [None] # type: ignore
routes = []
for host in hosts:
if host:
params.update({"requirements": {"host": host}})
ident = name
if len(hosts) > 1:
ident = (
f"{name}_{host.replace('.', '_')}"
if name
else "__unnamed__"
)
route = super().add(**params) # type: ignore
route.extra.ident = ident
route.extra.ignore_body = ignore_body
route.extra.stream = stream
route.extra.hosts = hosts
route.extra.static = static
route.extra.error_format = error_format
if error_format:
check_error_format(route.extra.error_format)
routes.append(route)
if len(routes) == 1:
return routes[0]
return routes
@lru_cache(maxsize=ROUTER_CACHE_SIZE)
def find_route_by_view_name(
self, view_name: str, name: str | None = None
) -> Route | None:
"""Find a route in the router based on the specified view name.
Args:
view_name (str): the name of the view to search for
name (Optional[str], optional): the name of the route. Defaults to `None`.
Returns:
Optional[Route]: the route object
""" # noqa: E501
if not view_name:
return None
route = self.name_index.get(view_name)
if not route:
full_name = self.ctx.app.generate_name(view_name)
route = self.name_index.get(full_name)
if not route:
return None
return route
@property
def routes_all(self) -> dict[tuple[str, ...], Route]:
"""Return all routes in the router.
Returns:
Dict[Tuple[str, ...], Route]: a dictionary of routes
"""
return {route.parts: route for route in self.routes}
@property
def routes_static(self) -> dict[tuple[str, ...], RouteGroup]:
"""Return all static routes in the router.
_In this context "static" routes do not refer to the `app.static()`
method. Instead, they refer to routes that do not contain
any path parameters._
Returns:
Dict[Tuple[str, ...], Route]: a dictionary of routes
"""
return self.static_routes
@property
def routes_dynamic(self) -> dict[tuple[str, ...], RouteGroup]:
"""Return all dynamic routes in the router.
_Dynamic routes are routes that contain path parameters._
Returns:
Dict[Tuple[str, ...], Route]: a dictionary of routes
"""
return self.dynamic_routes
@property
def routes_regex(self) -> dict[tuple[str, ...], RouteGroup]:
"""Return all regex routes in the router.
_Regex routes are routes that contain path parameters with regex
expressions, or otherwise need regex to resolve._
Returns:
Dict[Tuple[str, ...], Route]: a dictionary of routes
"""
return self.regex_routes
def finalize(self, *args, **kwargs) -> None:
"""Finalize the router.
Raises:
SanicException: if a route contains a parameter name that starts with "__" and is not in ALLOWED_LABELS
""" # noqa: E501
super().finalize(*args, **kwargs)
for route in self.dynamic_routes.values():
if any(
label.startswith("__") and label not in ALLOWED_LABELS
for label in route.labels
):
raise SanicException(
f"Invalid route: {route}. Parameter names cannot use '__'."
)
def _normalize(self, uri: str, handler: RouteHandler) -> str:
if "<" not in uri:
return uri
sig = signature(handler)
mapping = {
param.name: param.annotation.__name__.lower()
for param in sig.parameters.values()
if param.annotation in (str, int, float, UUID)
}
reconstruction = []
for part in uri.split("/"):
if part.startswith("<") and ":" not in part:
name = part[1:-1]
annotation = mapping.get(name)
if annotation:
part = f"<{name}:{annotation}>"
reconstruction.append(part)
return "/".join(reconstruction)
@@ -0,0 +1,15 @@
from sanic.models.server_types import ConnInfo, Signal
from sanic.server.async_server import AsyncioServer
from sanic.server.loop import try_use_uvloop
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.runners import serve
__all__ = (
"AsyncioServer",
"ConnInfo",
"HttpProtocol",
"Signal",
"serve",
"try_use_uvloop",
)
@@ -0,0 +1,112 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sanic.exceptions import SanicException
if TYPE_CHECKING:
from sanic import Sanic
class AsyncioServer:
"""Wraps an asyncio server with functionality that might be useful to a user who needs to manage the server lifecycle manually.""" # noqa: E501
__slots__ = ("app", "connections", "loop", "serve_coro", "server")
def __init__(
self,
app: Sanic,
loop,
serve_coro,
connections,
):
# Note, Sanic already called "before_server_start" events
# before this helper was even created. So we don't need it here.
self.app = app
self.connections = connections
self.loop = loop
self.serve_coro = serve_coro
self.server = None
def startup(self):
"""Trigger "startup" operations on the app"""
return self.app._startup()
def before_start(self):
"""Trigger "before_server_start" events"""
return self._server_event("init", "before")
def after_start(self):
"""Trigger "after_server_start" events"""
return self._server_event("init", "after")
def before_stop(self):
"""Trigger "before_server_stop" events"""
return self._server_event("shutdown", "before")
def after_stop(self):
"""Trigger "after_server_stop" events"""
return self._server_event("shutdown", "after")
def is_serving(self) -> bool:
"""Returns True if the server is running, False otherwise"""
if self.server:
return self.server.is_serving()
return False
def wait_closed(self):
"""Wait until the server is closed"""
if self.server:
return self.server.wait_closed()
def close(self):
"""Close the server"""
if self.server:
self.server.close()
coro = self.wait_closed()
task = self.loop.create_task(coro)
return task
def start_serving(self):
"""Start serving requests"""
return self._serve(self.server.start_serving)
def serve_forever(self):
"""Serve requests until the server is stopped"""
return self._serve(self.server.serve_forever)
def _serve(self, serve_func):
if self.server:
if not self.app.state.is_started:
raise SanicException(
"Cannot run Sanic server without first running "
"await server.startup()"
)
try:
return serve_func()
except AttributeError:
name = serve_func.__name__
raise NotImplementedError(
f"server.{name} not available in this version "
"of asyncio or uvloop."
)
def _server_event(self, concern: str, action: str):
if not self.app.state.is_started:
raise SanicException(
"Cannot dispatch server event without "
"first running await server.startup()"
)
return self.app._server_event(concern, action, loop=self.loop)
def __await__(self):
"""
Starts the asyncio server, returns AsyncServerCoro
"""
task = self.loop.create_task(self.serve_coro)
while not task.done():
yield
self.server = task.result()
return self
@@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Iterable
from inspect import isawaitable
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from sanic import Sanic
def trigger_events(
events: Iterable[Callable[..., Any]] | None,
loop,
app: Sanic | None = None,
**kwargs,
):
"""Trigger event callbacks (functions or async)
Args:
events (Optional[Iterable[Callable[..., Any]]]): [description]
loop ([type]): [description]
app (Optional[Sanic], optional): [description]. Defaults to None.
"""
if events:
for event in events:
try:
result = event(**kwargs) if not app else event(app, **kwargs)
except TypeError:
result = (
event(loop, **kwargs)
if not app
else event(app, loop, **kwargs)
)
if isawaitable(result):
loop.run_until_complete(result)
@@ -0,0 +1,31 @@
# flake8: noqa: E501
import random
import sys
# fmt: off
ascii_phrases = {
'Farewell', 'Bye', 'See you later', 'Take care', 'So long', 'Adieu', 'Cheerio',
'Goodbye', 'Adios', 'Au revoir', 'Arrivederci', 'Sayonara', 'Auf Wiedersehen',
'Do svidaniya', 'Annyeong', 'Tot ziens', 'Ha det', 'Selamat tinggal',
'Hasta luego', 'Nos vemos', 'Salut', 'Ciao', 'A presto',
'Dag', 'Tot later', 'Vi ses', 'Sampai jumpa',
}
non_ascii_phrases = {
'Tschüss', 'Zài jiàn', 'Bāi bāi', 'Míngtiān jiàn', 'Adeus', 'Tchau', 'Até logo',
'Hejdå', 'À bientôt', 'Bis später', 'Adjø',
'じゃね', 'またね', '안녕히 계세요', '잘 가', 'שלום',
'להתראות', 'مع السلامة', 'إلى اللقاء', 'وداعاً', 'अलविदा',
'फिर मिलेंगे',
}
all_phrases = ascii_phrases | non_ascii_phrases
# fmt: on
def get_goodbye() -> str: # pragma: no cover
is_utf8 = sys.stdout.encoding.lower() == "utf-8"
phrases = all_phrases if is_utf8 else ascii_phrases
return random.choice(list(phrases)) # nosec: B311
@@ -0,0 +1,64 @@
import asyncio
from os import getenv
from sanic.compat import OS_IS_WINDOWS
from sanic.log import error_logger
from sanic.utils import str_to_bool
def try_use_uvloop() -> None:
"""Use uvloop instead of the default asyncio loop."""
if OS_IS_WINDOWS:
error_logger.warning(
"You are trying to use uvloop, but uvloop is not compatible "
"with your system. You can disable uvloop completely by setting "
"the 'USE_UVLOOP' configuration value to false, or simply not "
"defining it and letting Sanic handle it for you. Sanic will now "
"continue to run using the default event loop."
)
return
try:
import uvloop # type: ignore
except ImportError:
error_logger.warning(
"You are trying to use uvloop, but uvloop is not "
"installed in your system. In order to use uvloop "
"you must first install it. Otherwise, you can disable "
"uvloop completely by setting the 'USE_UVLOOP' "
"configuration value to false. Sanic will now continue "
"to run with the default event loop."
)
return
uvloop_install_removed = str_to_bool(getenv("SANIC_NO_UVLOOP", "no"))
if uvloop_install_removed:
error_logger.info(
"You are requesting to run Sanic using uvloop, but the "
"install-time 'SANIC_NO_UVLOOP' environment variable (used to "
"opt-out of installing uvloop with Sanic) is set to true. If "
"you want to prevent Sanic from overriding the event loop policy "
"during runtime, set the 'USE_UVLOOP' configuration value to "
"false."
)
if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy):
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
def try_windows_loop():
"""Try to use the WindowsSelectorEventLoopPolicy instead of the default"""
if not OS_IS_WINDOWS:
error_logger.warning(
"You are trying to use an event loop policy that is not "
"compatible with your system. You can simply let Sanic handle "
"selecting the best loop for you. Sanic will now continue to run "
"using the default event loop."
)
return
if not isinstance(
asyncio.get_event_loop_policy(), asyncio.WindowsSelectorEventLoopPolicy
):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
@@ -0,0 +1,260 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sanic.exceptions import RequestCancelled
if TYPE_CHECKING:
from sanic.app import Sanic
import asyncio
from asyncio.transports import Transport
from time import monotonic as current_time
from sanic.log import error_logger
from sanic.models.server_types import ConnInfo, Signal
class SanicProtocol(asyncio.Protocol):
__slots__ = (
"app",
# event loop, connection
"loop",
"transport",
"connections",
"conn_info",
"signal",
"_can_write",
"_time",
"_task",
"_unix",
"_data_received",
)
def __init__(
self,
*,
loop,
app: Sanic,
signal=None,
connections=None,
unix=None,
**kwargs,
):
asyncio.set_event_loop(loop)
self.loop = loop
self.app: Sanic = app
self.signal = signal or Signal()
self.transport: Transport | None = None
self.connections = connections if connections is not None else set()
self.conn_info: ConnInfo | None = None
self._can_write = asyncio.Event()
self._can_write.set()
self._unix = unix
self._time = 0.0 # type: float
self._task: asyncio.Task | None = None
self._data_received = asyncio.Event()
@property
def ctx(self):
if self.conn_info is not None:
return self.conn_info.ctx
else:
return None
async def send(self, data):
"""
Generic data write implementation with backpressure control.
"""
await self._can_write.wait()
if self.transport.is_closing():
raise RequestCancelled
self.transport.write(data)
self._time = current_time()
async def receive_more(self):
"""
Wait until more data is received into the Server protocol's buffer
"""
self.transport.resume_reading()
self._data_received.clear()
await self._data_received.wait()
def close(self, timeout: float | None = None):
"""
Attempt close the connection.
"""
if self.transport is None or self.transport.is_closing():
# do not attempt to close again, already aborted or closing
return
# Check if write is already paused _before_ close() is called.
write_was_paused = not self._can_write.is_set()
# Trigger the UVLoop Stream Transport Close routine
# Causes a call to connection_lost where further cleanup occurs
# Close may fully close the connection now, but if there is still
# data in the libuv buffer, then close becomes an async operation
self.transport.close()
try:
# Check write-buffer data left _after_ close is called.
# in UVLoop, get the data in the libuv transport write-buffer
data_left = self.transport.get_write_buffer_size()
# Some asyncio implementations don't support get_write_buffer_size
except (AttributeError, NotImplementedError):
data_left = 0
if write_was_paused or data_left > 0:
# don't call resume_writing here, it gets called by the transport
# to unpause the protocol when it is ready for more data
# Schedule the async close checker, to close the connection
# after the transport is done, and clean everything up.
if timeout is None:
# This close timeout needs to be less than the graceful
# shutdown timeout. The graceful shutdown _could_ be waiting
# for this transport to close before shutting down the app.
timeout = self.app.config.GRACEFUL_TCP_CLOSE_TIMEOUT
# This is 5s by default.
else:
# Schedule the async close checker but with no timeout,
# this will ensure abort() is called if required.
if timeout is None:
timeout = 0
self.loop.call_soon(
_async_protocol_transport_close,
self,
self.loop,
timeout,
)
def abort(self):
"""
Force close the connection.
"""
# Cause a call to connection_lost where further cleanup occurs
if self.transport:
self.transport.abort()
self.transport = None
# asyncio.Protocol API Callbacks #
# ------------------------------ #
def connection_made(self, transport):
"""
Generic connection-made, with no connection_task, and no recv_buffer.
Override this for protocol-specific connection implementations.
"""
try:
transport.set_write_buffer_limits(low=16384, high=65536)
self.connections.add(self)
self.transport = transport
self.conn_info = ConnInfo(self.transport, unix=self._unix)
except Exception:
error_logger.exception("protocol.connect_made")
def connection_lost(self, exc):
"""
This is a callback handler that is called from the asyncio
transport layer implementation (eg, UVLoop's UVStreamTransport).
It is scheduled to be called async after the transport has closed.
When data is still in the send buffer, this call to connection_lost
will be delayed until _after_ the buffer is finished being sent.
So we can use this callback as a confirmation callback
that the async write-buffer transfer is finished.
"""
try:
self.connections.discard(self)
# unblock the send queue if it is paused,
# this allows the route handler to see
# the CancelledError exception
self.resume_writing()
self.conn_info.lost = True
if self._task:
self._task.cancel()
except BaseException:
error_logger.exception("protocol.connection_lost")
def pause_writing(self):
self._can_write.clear()
def resume_writing(self):
self._can_write.set()
def data_received(self, data: bytes):
try:
self._time = current_time()
if not data:
return self.close()
if self._data_received:
self._data_received.set()
except BaseException:
error_logger.exception("protocol.data_received")
def _async_protocol_transport_close(
protocol: SanicProtocol,
loop: asyncio.AbstractEventLoop,
timeout: float,
):
"""
This function is scheduled to be called after close() is called.
It checks that the transport has shut down properly, or waits
for any remaining data to be sent, and aborts after a timeout.
This is required if the transport is closed while there is an async
large async transport write operation in progress.
This is observed when NGINX reverse-proxy is the client.
"""
if protocol.transport is None:
# abort() is the only method that can make
# protocol.transport be None, so abort was already called
return
# protocol.connection_lost does not set protocol.transport to None
# so to detect it a different way with conninfo.lost
elif protocol.conn_info is not None and protocol.conn_info.lost:
# Terminus. Most connections finish the protocol here!
# Connection_lost callback was executed already,
# so transport did complete and close itself properly.
# No need to call abort().
# This is the last part of cleanup to do
# that is not done by connection_lost handler.
# Ensure transport is cleaned up by GC.
protocol.transport = None
return
elif not protocol.transport.is_closing():
raise RuntimeError(
"You must call transport.close() before "
"protocol._async_transport_close() runs."
)
write_is_paused = not protocol._can_write.is_set()
try:
# in UVLoop, get the data in the libuv write-buffer
data_left = protocol.transport.get_write_buffer_size()
# Some asyncio implementations don't support get_write_buffer_size
except (AttributeError, NotImplementedError):
data_left = 0
if write_is_paused or data_left > 0:
# don't need to call resume_writing here to unpause
if timeout <= 0:
# timeout is 0 or less, so we can simply abort now
loop.call_soon(SanicProtocol.abort, protocol)
else:
next_check_interval = min(timeout, 0.1)
next_check_timeout = timeout - next_check_interval
loop.call_later(
# Recurse back in after the timeout, to check again
next_check_interval,
# this next time with reduced timeout.
_async_protocol_transport_close,
protocol,
loop,
next_check_timeout,
)
else:
# Not paused, and no data left in the buffer, but transport
# is still open, connection_lost has not been called yet.
# We can call abort() to fix that.
loop.call_soon(SanicProtocol.abort, protocol)

Some files were not shown because too many files have changed in this diff Show More