chore: rename project coven → hack-house ⛧
Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"), README, CLI strings, and branch (coven → hack-house). Gitea repo renamed cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest + golden-vector test still green, binary is now `hack-house`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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("&", "&").replace("<", "<")
|
||||
|
||||
|
||||
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
Reference in New Issue
Block a user