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,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__()
|
||||
Reference in New Issue
Block a user