Загрузить файлы в «venv/Lib/site-packages/starlette»
This commit is contained in:
567
venv/Lib/site-packages/starlette/responses.py
Normal file
567
venv/Lib/site-packages/starlette/responses.py
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import http.cookies
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from email.utils import format_datetime, formatdate
|
||||||
|
from functools import partial
|
||||||
|
from mimetypes import guess_type
|
||||||
|
from secrets import token_hex
|
||||||
|
from typing import Any, Literal
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import anyio.to_thread
|
||||||
|
|
||||||
|
from starlette._utils import create_collapsing_task_group
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
from starlette.concurrency import iterate_in_threadpool
|
||||||
|
from starlette.datastructures import URL, Headers, MutableHeaders
|
||||||
|
from starlette.requests import ClientDisconnect
|
||||||
|
from starlette.types import Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
media_type = None
|
||||||
|
charset = "utf-8"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
content: Any = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
background: BackgroundTask | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
if media_type is not None:
|
||||||
|
self.media_type = media_type
|
||||||
|
self.background = background
|
||||||
|
self.body = self.render(content)
|
||||||
|
self.init_headers(headers)
|
||||||
|
|
||||||
|
def render(self, content: Any) -> bytes | memoryview:
|
||||||
|
if content is None:
|
||||||
|
return b""
|
||||||
|
if isinstance(content, bytes | memoryview):
|
||||||
|
return content
|
||||||
|
return content.encode(self.charset) # type: ignore
|
||||||
|
|
||||||
|
def init_headers(self, headers: Mapping[str, str] | None = None) -> None:
|
||||||
|
if headers is None:
|
||||||
|
raw_headers: list[tuple[bytes, bytes]] = []
|
||||||
|
populate_content_length = True
|
||||||
|
populate_content_type = True
|
||||||
|
else:
|
||||||
|
raw_headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in headers.items()]
|
||||||
|
keys = [h[0] for h in raw_headers]
|
||||||
|
populate_content_length = b"content-length" not in keys
|
||||||
|
populate_content_type = b"content-type" not in keys
|
||||||
|
|
||||||
|
body = getattr(self, "body", None)
|
||||||
|
if (
|
||||||
|
body is not None
|
||||||
|
and populate_content_length
|
||||||
|
and not (self.status_code < 200 or self.status_code in (204, 304))
|
||||||
|
):
|
||||||
|
content_length = str(len(body))
|
||||||
|
raw_headers.append((b"content-length", content_length.encode("latin-1")))
|
||||||
|
|
||||||
|
content_type = self.media_type
|
||||||
|
if content_type is not None and populate_content_type:
|
||||||
|
if content_type.startswith("text/") and "charset=" not in content_type.lower():
|
||||||
|
content_type += "; charset=" + self.charset
|
||||||
|
raw_headers.append((b"content-type", content_type.encode("latin-1")))
|
||||||
|
|
||||||
|
self.raw_headers = raw_headers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def headers(self) -> MutableHeaders:
|
||||||
|
if not hasattr(self, "_headers"):
|
||||||
|
self._headers = MutableHeaders(raw=self.raw_headers)
|
||||||
|
return self._headers
|
||||||
|
|
||||||
|
def set_cookie(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: str = "",
|
||||||
|
max_age: int | None = None,
|
||||||
|
expires: datetime | str | int | None = None,
|
||||||
|
path: str | None = "/",
|
||||||
|
domain: str | None = None,
|
||||||
|
secure: bool = False,
|
||||||
|
httponly: bool = False,
|
||||||
|
samesite: Literal["lax", "strict", "none"] | None = "lax",
|
||||||
|
partitioned: bool = False,
|
||||||
|
) -> None:
|
||||||
|
cookie: http.cookies.BaseCookie[str] = http.cookies.SimpleCookie()
|
||||||
|
cookie[key] = value
|
||||||
|
if max_age is not None:
|
||||||
|
cookie[key]["max-age"] = max_age
|
||||||
|
if expires is not None:
|
||||||
|
if isinstance(expires, datetime):
|
||||||
|
cookie[key]["expires"] = format_datetime(expires, usegmt=True)
|
||||||
|
else:
|
||||||
|
cookie[key]["expires"] = expires
|
||||||
|
if path is not None:
|
||||||
|
cookie[key]["path"] = path
|
||||||
|
if domain is not None:
|
||||||
|
cookie[key]["domain"] = domain
|
||||||
|
if secure:
|
||||||
|
cookie[key]["secure"] = True
|
||||||
|
if httponly:
|
||||||
|
cookie[key]["httponly"] = True
|
||||||
|
if samesite is not None:
|
||||||
|
assert samesite.lower() in [
|
||||||
|
"strict",
|
||||||
|
"lax",
|
||||||
|
"none",
|
||||||
|
], "samesite must be either 'strict', 'lax' or 'none'"
|
||||||
|
cookie[key]["samesite"] = samesite
|
||||||
|
if partitioned:
|
||||||
|
if sys.version_info < (3, 14):
|
||||||
|
raise ValueError("Partitioned cookies are only supported in Python 3.14 and above.") # pragma: no cover
|
||||||
|
cookie[key]["partitioned"] = True # pragma: no cover
|
||||||
|
|
||||||
|
cookie_val = cookie.output(header="").strip()
|
||||||
|
self.raw_headers.append((b"set-cookie", cookie_val.encode("latin-1")))
|
||||||
|
|
||||||
|
def delete_cookie(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
path: str = "/",
|
||||||
|
domain: str | None = None,
|
||||||
|
secure: bool = False,
|
||||||
|
httponly: bool = False,
|
||||||
|
samesite: Literal["lax", "strict", "none"] | None = "lax",
|
||||||
|
) -> None:
|
||||||
|
self.set_cookie(
|
||||||
|
key,
|
||||||
|
max_age=0,
|
||||||
|
expires=0,
|
||||||
|
path=path,
|
||||||
|
domain=domain,
|
||||||
|
secure=secure,
|
||||||
|
httponly=httponly,
|
||||||
|
samesite=samesite,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wrap_websocket_denial_send(self, send: Send) -> Send:
|
||||||
|
async def wrapped(message: Message) -> None:
|
||||||
|
message_type = message["type"]
|
||||||
|
if message_type in {"http.response.start", "http.response.body"}: # pragma: no branch
|
||||||
|
message = {**message, "type": "websocket." + message_type}
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
send = self._wrap_websocket_denial_send(send)
|
||||||
|
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
|
||||||
|
await send({"type": "http.response.body", "body": self.body})
|
||||||
|
|
||||||
|
if self.background is not None:
|
||||||
|
await self.background()
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLResponse(Response):
|
||||||
|
media_type = "text/html"
|
||||||
|
|
||||||
|
|
||||||
|
class PlainTextResponse(Response):
|
||||||
|
media_type = "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
class JSONResponse(Response):
|
||||||
|
media_type = "application/json"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
content: Any,
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
background: BackgroundTask | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(content, status_code, headers, media_type, background)
|
||||||
|
|
||||||
|
def render(self, content: Any) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
content,
|
||||||
|
ensure_ascii=False,
|
||||||
|
allow_nan=False,
|
||||||
|
indent=None,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectResponse(Response):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | URL,
|
||||||
|
status_code: int = 307,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
background: BackgroundTask | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(content=b"", status_code=status_code, headers=headers, background=background)
|
||||||
|
self.headers["location"] = quote(str(url), safe=":/%#?=@[]!$&'()*+,;")
|
||||||
|
|
||||||
|
|
||||||
|
Content = str | bytes | memoryview
|
||||||
|
SyncContentStream = Iterable[Content]
|
||||||
|
AsyncContentStream = AsyncIterable[Content]
|
||||||
|
ContentStream = AsyncContentStream | SyncContentStream
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingResponse(Response):
|
||||||
|
body_iterator: AsyncContentStream
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
content: ContentStream,
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
background: BackgroundTask | None = None,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(content, AsyncIterable):
|
||||||
|
self.body_iterator = content
|
||||||
|
else:
|
||||||
|
self.body_iterator = iterate_in_threadpool(content)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.media_type = self.media_type if media_type is None else media_type
|
||||||
|
self.background = background
|
||||||
|
self.init_headers(headers)
|
||||||
|
|
||||||
|
async def listen_for_disconnect(self, receive: Receive) -> None:
|
||||||
|
while True:
|
||||||
|
message = await receive()
|
||||||
|
if message["type"] == "http.disconnect":
|
||||||
|
break
|
||||||
|
|
||||||
|
async def stream_response(self, send: Send) -> None:
|
||||||
|
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
|
||||||
|
async for chunk in self.body_iterator:
|
||||||
|
if not isinstance(chunk, bytes | memoryview):
|
||||||
|
chunk = chunk.encode(self.charset)
|
||||||
|
await send({"type": "http.response.body", "body": chunk, "more_body": True})
|
||||||
|
|
||||||
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
send = self._wrap_websocket_denial_send(send)
|
||||||
|
await self.stream_response(send)
|
||||||
|
if self.background is not None:
|
||||||
|
await self.background()
|
||||||
|
return
|
||||||
|
|
||||||
|
spec_version = tuple(map(int, scope.get("asgi", {}).get("spec_version", "2.0").split(".")))
|
||||||
|
|
||||||
|
if spec_version >= (2, 4):
|
||||||
|
try:
|
||||||
|
await self.stream_response(send)
|
||||||
|
except OSError:
|
||||||
|
raise ClientDisconnect()
|
||||||
|
else:
|
||||||
|
async with create_collapsing_task_group() as task_group:
|
||||||
|
|
||||||
|
async def wrap(func: Callable[[], Awaitable[None]]) -> None:
|
||||||
|
await func()
|
||||||
|
task_group.cancel_scope.cancel()
|
||||||
|
|
||||||
|
task_group.start_soon(wrap, partial(self.stream_response, send))
|
||||||
|
await wrap(partial(self.listen_for_disconnect, receive))
|
||||||
|
|
||||||
|
if self.background is not None:
|
||||||
|
await self.background()
|
||||||
|
|
||||||
|
|
||||||
|
class MalformedRangeHeader(Exception):
|
||||||
|
def __init__(self, content: str = "Malformed range header.") -> None:
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
|
||||||
|
class RangeNotSatisfiable(Exception):
|
||||||
|
def __init__(self, max_size: int) -> None:
|
||||||
|
self.max_size = max_size
|
||||||
|
|
||||||
|
|
||||||
|
class FileResponse(Response):
|
||||||
|
chunk_size = 64 * 1024
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | os.PathLike[str],
|
||||||
|
status_code: int = 200,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
background: BackgroundTask | None = None,
|
||||||
|
filename: str | None = None,
|
||||||
|
stat_result: os.stat_result | None = None,
|
||||||
|
content_disposition_type: str = "attachment",
|
||||||
|
) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.status_code = status_code
|
||||||
|
self.filename = filename
|
||||||
|
if media_type is None:
|
||||||
|
media_type = guess_type(filename or path)[0] or "application/octet-stream"
|
||||||
|
self.media_type = media_type
|
||||||
|
self.background = background
|
||||||
|
self.init_headers(headers)
|
||||||
|
self.headers.setdefault("accept-ranges", "bytes")
|
||||||
|
if self.filename is not None:
|
||||||
|
content_disposition_filename = quote(self.filename)
|
||||||
|
if content_disposition_filename != self.filename:
|
||||||
|
content_disposition = f"{content_disposition_type}; filename*=utf-8''{content_disposition_filename}"
|
||||||
|
else:
|
||||||
|
content_disposition = f'{content_disposition_type}; filename="{self.filename}"'
|
||||||
|
self.headers.setdefault("content-disposition", content_disposition)
|
||||||
|
self.stat_result = stat_result
|
||||||
|
if stat_result is not None:
|
||||||
|
self.set_stat_headers(stat_result)
|
||||||
|
|
||||||
|
def set_stat_headers(self, stat_result: os.stat_result) -> None:
|
||||||
|
content_length = str(stat_result.st_size)
|
||||||
|
last_modified = formatdate(stat_result.st_mtime, usegmt=True)
|
||||||
|
etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size)
|
||||||
|
etag = f'"{hashlib.md5(etag_base.encode(), usedforsecurity=False).hexdigest()}"'
|
||||||
|
|
||||||
|
self.headers.setdefault("content-length", content_length)
|
||||||
|
self.headers.setdefault("last-modified", last_modified)
|
||||||
|
self.headers.setdefault("etag", etag)
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
scope_type = scope["type"]
|
||||||
|
send_header_only = scope_type == "http" and scope["method"].upper() == "HEAD"
|
||||||
|
send_pathsend = scope_type == "http" and "http.response.pathsend" in scope.get("extensions", {})
|
||||||
|
if scope_type == "websocket":
|
||||||
|
send = self._wrap_websocket_denial_send(send)
|
||||||
|
|
||||||
|
if self.stat_result is None:
|
||||||
|
try:
|
||||||
|
stat_result = await anyio.to_thread.run_sync(os.stat, self.path)
|
||||||
|
self.set_stat_headers(stat_result)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise RuntimeError(f"File at path {self.path} does not exist.")
|
||||||
|
else:
|
||||||
|
mode = stat_result.st_mode
|
||||||
|
if not stat.S_ISREG(mode):
|
||||||
|
raise RuntimeError(f"File at path {self.path} is not a file.")
|
||||||
|
else:
|
||||||
|
stat_result = self.stat_result
|
||||||
|
|
||||||
|
headers = Headers(scope=scope)
|
||||||
|
http_range = headers.get("range")
|
||||||
|
http_if_range = headers.get("if-range")
|
||||||
|
|
||||||
|
if http_range is None or (http_if_range is not None and not self._should_use_range(http_if_range)):
|
||||||
|
await self._handle_simple(send, send_header_only, send_pathsend)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ranges = self._parse_range_header(http_range, stat_result.st_size)
|
||||||
|
except MalformedRangeHeader as exc:
|
||||||
|
return await PlainTextResponse(exc.content, status_code=400)(scope, receive, send)
|
||||||
|
except RangeNotSatisfiable as exc:
|
||||||
|
response = PlainTextResponse(status_code=416, headers={"Content-Range": f"bytes */{exc.max_size}"})
|
||||||
|
return await response(scope, receive, send)
|
||||||
|
|
||||||
|
if len(ranges) == 1:
|
||||||
|
start, end = ranges[0]
|
||||||
|
await self._handle_single_range(send, start, end, stat_result.st_size, send_header_only)
|
||||||
|
else:
|
||||||
|
await self._handle_multiple_ranges(send, ranges, stat_result.st_size, send_header_only)
|
||||||
|
|
||||||
|
if self.background is not None:
|
||||||
|
await self.background()
|
||||||
|
|
||||||
|
async def _handle_simple(self, send: Send, send_header_only: bool, send_pathsend: bool) -> None:
|
||||||
|
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
|
||||||
|
if send_header_only:
|
||||||
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
|
elif send_pathsend:
|
||||||
|
await send({"type": "http.response.pathsend", "path": str(self.path)})
|
||||||
|
else:
|
||||||
|
async with await anyio.open_file(self.path, mode="rb") as file:
|
||||||
|
more_body = True
|
||||||
|
while more_body:
|
||||||
|
chunk = await file.read(self.chunk_size)
|
||||||
|
more_body = len(chunk) == self.chunk_size
|
||||||
|
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
|
||||||
|
|
||||||
|
async def _handle_single_range(
|
||||||
|
self, send: Send, start: int, end: int, file_size: int, send_header_only: bool
|
||||||
|
) -> None:
|
||||||
|
headers = MutableHeaders(raw=list(self.raw_headers))
|
||||||
|
headers["content-range"] = f"bytes {start}-{end - 1}/{file_size}"
|
||||||
|
headers["content-length"] = str(end - start)
|
||||||
|
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
|
||||||
|
if send_header_only:
|
||||||
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
|
else:
|
||||||
|
async with await anyio.open_file(self.path, mode="rb") as file:
|
||||||
|
await file.seek(start)
|
||||||
|
more_body = True
|
||||||
|
while more_body:
|
||||||
|
chunk = await file.read(min(self.chunk_size, end - start))
|
||||||
|
start += len(chunk)
|
||||||
|
more_body = len(chunk) == self.chunk_size and start < end
|
||||||
|
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
|
||||||
|
|
||||||
|
async def _handle_multiple_ranges(
|
||||||
|
self,
|
||||||
|
send: Send,
|
||||||
|
ranges: list[tuple[int, int]],
|
||||||
|
file_size: int,
|
||||||
|
send_header_only: bool,
|
||||||
|
) -> None:
|
||||||
|
# In firefox and chrome, they use boundary with 95-96 bits entropy (that's roughly 13 bytes).
|
||||||
|
boundary = token_hex(13)
|
||||||
|
content_length, header_generator = self.generate_multipart(
|
||||||
|
ranges, boundary, file_size, self.headers["content-type"]
|
||||||
|
)
|
||||||
|
headers = MutableHeaders(raw=list(self.raw_headers))
|
||||||
|
headers["content-type"] = f"multipart/byteranges; boundary={boundary}"
|
||||||
|
headers["content-length"] = str(content_length)
|
||||||
|
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
|
||||||
|
if send_header_only:
|
||||||
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||||
|
else:
|
||||||
|
async with await anyio.open_file(self.path, mode="rb") as file:
|
||||||
|
for start, end in ranges:
|
||||||
|
await send({"type": "http.response.body", "body": header_generator(start, end), "more_body": True})
|
||||||
|
await file.seek(start)
|
||||||
|
while start < end:
|
||||||
|
chunk = await file.read(min(self.chunk_size, end - start))
|
||||||
|
start += len(chunk)
|
||||||
|
await send({"type": "http.response.body", "body": chunk, "more_body": True})
|
||||||
|
await send({"type": "http.response.body", "body": b"\r\n", "more_body": True})
|
||||||
|
await send(
|
||||||
|
{
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": f"--{boundary}--".encode("latin-1"),
|
||||||
|
"more_body": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_use_range(self, http_if_range: str) -> bool:
|
||||||
|
return http_if_range == self.headers["last-modified"] or http_if_range == self.headers["etag"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_range_header(cls, http_range: str, file_size: int) -> list[tuple[int, int]]:
|
||||||
|
ranges: list[tuple[int, int]] = []
|
||||||
|
try:
|
||||||
|
units, range_ = http_range.split("=", 1)
|
||||||
|
except ValueError:
|
||||||
|
raise MalformedRangeHeader()
|
||||||
|
|
||||||
|
units = units.strip().lower()
|
||||||
|
|
||||||
|
if units != "bytes":
|
||||||
|
raise MalformedRangeHeader("Only support bytes range")
|
||||||
|
|
||||||
|
ranges = cls._parse_ranges(range_, file_size)
|
||||||
|
|
||||||
|
if len(ranges) == 0:
|
||||||
|
raise MalformedRangeHeader("Range header: range must be requested")
|
||||||
|
|
||||||
|
if any(not (0 <= start < file_size) for start, _ in ranges):
|
||||||
|
raise RangeNotSatisfiable(file_size)
|
||||||
|
|
||||||
|
if any(start > end for start, end in ranges):
|
||||||
|
raise MalformedRangeHeader("Range header: start must be less than end")
|
||||||
|
|
||||||
|
if len(ranges) == 1:
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
# Merge overlapping ranges
|
||||||
|
ranges.sort()
|
||||||
|
result: list[tuple[int, int]] = [ranges[0]]
|
||||||
|
for start, end in ranges[1:]:
|
||||||
|
last_start, last_end = result[-1]
|
||||||
|
if start <= last_end:
|
||||||
|
result[-1] = (last_start, max(last_end, end))
|
||||||
|
else:
|
||||||
|
result.append((start, end))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_ranges(cls, range_: str, file_size: int) -> list[tuple[int, int]]:
|
||||||
|
ranges: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
for part in range_.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
|
||||||
|
# If the range is empty or a single dash, we ignore it.
|
||||||
|
if not part or part == "-":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If the range is not in the format "start-end", we ignore it.
|
||||||
|
if "-" not in part:
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_str, end_str = part.split("-", 1)
|
||||||
|
start_str = start_str.strip()
|
||||||
|
end_str = end_str.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = int(start_str) if start_str else max(file_size - int(end_str), 0)
|
||||||
|
end = int(end_str) + 1 if start_str and end_str and int(end_str) < file_size else file_size
|
||||||
|
ranges.append((start, end))
|
||||||
|
except ValueError:
|
||||||
|
# If the range is not numeric, we ignore it.
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
|
||||||
|
def generate_multipart(
|
||||||
|
self,
|
||||||
|
ranges: Sequence[tuple[int, int]],
|
||||||
|
boundary: str,
|
||||||
|
max_size: int,
|
||||||
|
content_type: str,
|
||||||
|
) -> tuple[int, Callable[[int, int], bytes]]:
|
||||||
|
r"""
|
||||||
|
Multipart response headers generator.
|
||||||
|
|
||||||
|
```
|
||||||
|
--{boundary}\r\n
|
||||||
|
Content-Type: {content_type}\r\n
|
||||||
|
Content-Range: bytes {start}-{end-1}/{max_size}\r\n
|
||||||
|
\r\n
|
||||||
|
..........content...........\r\n
|
||||||
|
--{boundary}\r\n
|
||||||
|
Content-Type: {content_type}\r\n
|
||||||
|
Content-Range: bytes {start}-{end-1}/{max_size}\r\n
|
||||||
|
\r\n
|
||||||
|
..........content...........\r\n
|
||||||
|
--{boundary}--
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
boundary_len = len(boundary)
|
||||||
|
static_header_part_len = 49 + boundary_len + len(content_type) + len(str(max_size))
|
||||||
|
content_length = sum(
|
||||||
|
(len(str(start)) + len(str(end - 1)) + static_header_part_len) # Headers
|
||||||
|
+ (end - start) # Content
|
||||||
|
for start, end in ranges
|
||||||
|
) + (
|
||||||
|
4 + boundary_len # --boundary--
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
content_length,
|
||||||
|
lambda start, end: (
|
||||||
|
f"""\
|
||||||
|
--{boundary}\r
|
||||||
|
Content-Type: {content_type}\r
|
||||||
|
Content-Range: bytes {start}-{end - 1}/{max_size}\r
|
||||||
|
\r
|
||||||
|
"""
|
||||||
|
).encode("latin-1"),
|
||||||
|
)
|
||||||
747
venv/Lib/site-packages/starlette/routing.py
Normal file
747
venv/Lib/site-packages/starlette/routing.py
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
import types
|
||||||
|
import warnings
|
||||||
|
from collections.abc import Awaitable, Callable, Collection, Generator, Sequence
|
||||||
|
from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager
|
||||||
|
from enum import Enum
|
||||||
|
from re import Pattern
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from starlette._exception_handler import wrap_app_handling_exceptions
|
||||||
|
from starlette._utils import get_route_path, is_async_callable
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
from starlette.convertors import CONVERTOR_TYPES, Convertor
|
||||||
|
from starlette.datastructures import URL, Headers, URLPath
|
||||||
|
from starlette.exceptions import HTTPException, StarletteDeprecationWarning
|
||||||
|
from starlette.middleware import Middleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import PlainTextResponse, RedirectResponse, Response
|
||||||
|
from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
|
||||||
|
from starlette.websockets import WebSocket, WebSocketClose
|
||||||
|
|
||||||
|
|
||||||
|
class NoMatchFound(Exception):
|
||||||
|
"""
|
||||||
|
Raised by `.url_for(name, **path_params)` and `.url_path_for(name, **path_params)`
|
||||||
|
if no matching route exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, path_params: dict[str, Any]) -> None:
|
||||||
|
params = ", ".join(list(path_params.keys()))
|
||||||
|
super().__init__(f'No route exists for name "{name}" and params "{params}".')
|
||||||
|
|
||||||
|
|
||||||
|
class Match(Enum):
|
||||||
|
NONE = 0
|
||||||
|
PARTIAL = 1
|
||||||
|
FULL = 2
|
||||||
|
|
||||||
|
|
||||||
|
def request_response(
|
||||||
|
func: Callable[[Request], Awaitable[Response] | Response],
|
||||||
|
) -> ASGIApp:
|
||||||
|
"""
|
||||||
|
Takes a function or coroutine `func(request) -> response`,
|
||||||
|
and returns an ASGI application.
|
||||||
|
"""
|
||||||
|
f: Callable[[Request], Awaitable[Response]] = (
|
||||||
|
func if is_async_callable(func) else functools.partial(run_in_threadpool, func) # type: ignore[assignment, call-arg]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
request = Request(scope, receive, send)
|
||||||
|
|
||||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
response = await f(request)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
|
||||||
|
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_session(
|
||||||
|
func: Callable[[WebSocket], Awaitable[None]],
|
||||||
|
) -> ASGIApp:
|
||||||
|
"""
|
||||||
|
Takes a coroutine `func(session)`, and returns an ASGI application.
|
||||||
|
"""
|
||||||
|
# assert asyncio.iscoroutinefunction(func), "WebSocket endpoints must be async"
|
||||||
|
|
||||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
session = WebSocket(scope, receive=receive, send=send)
|
||||||
|
|
||||||
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
await func(session)
|
||||||
|
|
||||||
|
await wrap_app_handling_exceptions(app, session)(scope, receive, send)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def get_name(endpoint: Callable[..., Any]) -> str:
|
||||||
|
return getattr(endpoint, "__name__", endpoint.__class__.__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_params(
|
||||||
|
path: str,
|
||||||
|
param_convertors: dict[str, Convertor[Any]],
|
||||||
|
path_params: dict[str, str],
|
||||||
|
) -> tuple[str, dict[str, str]]:
|
||||||
|
for key, value in list(path_params.items()):
|
||||||
|
if "{" + key + "}" in path:
|
||||||
|
convertor = param_convertors[key]
|
||||||
|
value = convertor.to_string(value)
|
||||||
|
path = path.replace("{" + key + "}", value)
|
||||||
|
path_params.pop(key)
|
||||||
|
return path, path_params
|
||||||
|
|
||||||
|
|
||||||
|
# Match parameters in URL paths, eg. '{param}', and '{param:int}'
|
||||||
|
PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
|
||||||
|
|
||||||
|
|
||||||
|
def compile_path(
|
||||||
|
path: str,
|
||||||
|
) -> tuple[Pattern[str], str, dict[str, Convertor[Any]]]:
|
||||||
|
"""
|
||||||
|
Given a path string, like: "/{username:str}",
|
||||||
|
or a host string, like: "{subdomain}.mydomain.org", return a three-tuple
|
||||||
|
of (regex, format, {param_name:convertor}).
|
||||||
|
|
||||||
|
regex: "/(?P<username>[^/]+)"
|
||||||
|
format: "/{username}"
|
||||||
|
convertors: {"username": StringConvertor()}
|
||||||
|
"""
|
||||||
|
is_host = not path.startswith("/")
|
||||||
|
|
||||||
|
path_regex = "^"
|
||||||
|
path_format = ""
|
||||||
|
duplicated_params: set[str] = set()
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
param_convertors = {}
|
||||||
|
for match in PARAM_REGEX.finditer(path):
|
||||||
|
param_name, convertor_type = match.groups("str")
|
||||||
|
convertor_type = convertor_type.lstrip(":")
|
||||||
|
assert convertor_type in CONVERTOR_TYPES, f"Unknown path convertor '{convertor_type}'"
|
||||||
|
convertor = CONVERTOR_TYPES[convertor_type]
|
||||||
|
|
||||||
|
path_regex += re.escape(path[idx : match.start()])
|
||||||
|
path_regex += f"(?P<{param_name}>{convertor.regex})"
|
||||||
|
|
||||||
|
path_format += path[idx : match.start()]
|
||||||
|
path_format += "{%s}" % param_name
|
||||||
|
|
||||||
|
if param_name in param_convertors:
|
||||||
|
duplicated_params.add(param_name)
|
||||||
|
|
||||||
|
param_convertors[param_name] = convertor
|
||||||
|
|
||||||
|
idx = match.end()
|
||||||
|
|
||||||
|
if duplicated_params:
|
||||||
|
names = ", ".join(sorted(duplicated_params))
|
||||||
|
ending = "s" if len(duplicated_params) > 1 else ""
|
||||||
|
raise ValueError(f"Duplicated param name{ending} {names} at path {path}")
|
||||||
|
|
||||||
|
if is_host:
|
||||||
|
# Align with `Host.matches()` behavior, which ignores port.
|
||||||
|
hostname = path[idx:].split(":")[0]
|
||||||
|
path_regex += re.escape(hostname) + "$"
|
||||||
|
else:
|
||||||
|
path_regex += re.escape(path[idx:]) + "$"
|
||||||
|
|
||||||
|
path_format += path[idx:]
|
||||||
|
|
||||||
|
return re.compile(path_regex), path_format, param_convertors
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRoute:
|
||||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||||
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||||
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
"""
|
||||||
|
A route may be used in isolation as a stand-alone ASGI app.
|
||||||
|
This is a somewhat contrived case, as they'll almost always be used
|
||||||
|
within a Router, but could be useful for some tooling and minimal apps.
|
||||||
|
"""
|
||||||
|
match, child_scope = self.matches(scope)
|
||||||
|
if match == Match.NONE:
|
||||||
|
if scope["type"] == "http":
|
||||||
|
response = PlainTextResponse("Not Found", status_code=404)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
elif scope["type"] == "websocket": # pragma: no branch
|
||||||
|
websocket_close = WebSocketClose()
|
||||||
|
await websocket_close(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
scope.update(child_scope)
|
||||||
|
await self.handle(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
class Route(BaseRoute):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
endpoint: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
methods: Collection[str] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
include_in_schema: bool = True,
|
||||||
|
middleware: Sequence[Middleware] | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert path.startswith("/"), "Routed paths must start with '/'"
|
||||||
|
self.path = path
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.name = get_name(endpoint) if name is None else name
|
||||||
|
self.include_in_schema = include_in_schema
|
||||||
|
|
||||||
|
endpoint_handler = endpoint
|
||||||
|
while isinstance(endpoint_handler, functools.partial):
|
||||||
|
endpoint_handler = endpoint_handler.func
|
||||||
|
if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
|
||||||
|
# Endpoint is function or method. Treat it as `func(request) -> response`.
|
||||||
|
self.app = request_response(endpoint)
|
||||||
|
if methods is None:
|
||||||
|
methods = ["GET"]
|
||||||
|
else:
|
||||||
|
# Endpoint is a class. Treat it as ASGI.
|
||||||
|
self.app = endpoint
|
||||||
|
|
||||||
|
if middleware is not None:
|
||||||
|
for cls, args, kwargs in reversed(middleware):
|
||||||
|
self.app = cls(self.app, *args, **kwargs)
|
||||||
|
|
||||||
|
if methods is None:
|
||||||
|
self.methods = None
|
||||||
|
else:
|
||||||
|
self.methods = {method.upper() for method in methods}
|
||||||
|
if "GET" in self.methods:
|
||||||
|
self.methods.add("HEAD")
|
||||||
|
|
||||||
|
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
|
||||||
|
|
||||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||||
|
path_params: dict[str, Any]
|
||||||
|
if scope["type"] == "http":
|
||||||
|
route_path = get_route_path(scope)
|
||||||
|
match = self.path_regex.match(route_path)
|
||||||
|
if match:
|
||||||
|
matched_params = match.groupdict()
|
||||||
|
for key, value in matched_params.items():
|
||||||
|
matched_params[key] = self.param_convertors[key].convert(value)
|
||||||
|
path_params = dict(scope.get("path_params", {}))
|
||||||
|
path_params.update(matched_params)
|
||||||
|
child_scope = {"endpoint": self.endpoint, "path_params": path_params}
|
||||||
|
if self.methods and scope["method"] not in self.methods:
|
||||||
|
return Match.PARTIAL, child_scope
|
||||||
|
else:
|
||||||
|
return Match.FULL, child_scope
|
||||||
|
return Match.NONE, {}
|
||||||
|
|
||||||
|
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||||
|
seen_params = set(path_params.keys())
|
||||||
|
expected_params = set(self.param_convertors.keys())
|
||||||
|
|
||||||
|
if name != self.name or seen_params != expected_params:
|
||||||
|
raise NoMatchFound(name, path_params)
|
||||||
|
|
||||||
|
path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
|
||||||
|
assert not remaining_params
|
||||||
|
return URLPath(path=path, protocol="http")
|
||||||
|
|
||||||
|
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if self.methods and scope["method"] not in self.methods:
|
||||||
|
headers = {"Allow": ", ".join(self.methods)}
|
||||||
|
if "app" in scope:
|
||||||
|
raise HTTPException(status_code=405, headers=headers)
|
||||||
|
else:
|
||||||
|
response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
else:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(other, Route)
|
||||||
|
and self.path == other.path
|
||||||
|
and self.endpoint == other.endpoint
|
||||||
|
and self.methods == other.methods
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
class_name = self.__class__.__name__
|
||||||
|
methods = sorted(self.methods or [])
|
||||||
|
path, name = self.path, self.name
|
||||||
|
return f"{class_name}(path={path!r}, name={name!r}, methods={methods!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketRoute(BaseRoute):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
endpoint: Callable[..., Any],
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
middleware: Sequence[Middleware] | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert path.startswith("/"), "Routed paths must start with '/'"
|
||||||
|
self.path = path
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.name = get_name(endpoint) if name is None else name
|
||||||
|
|
||||||
|
endpoint_handler = endpoint
|
||||||
|
while isinstance(endpoint_handler, functools.partial):
|
||||||
|
endpoint_handler = endpoint_handler.func
|
||||||
|
if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
|
||||||
|
# Endpoint is function or method. Treat it as `func(websocket)`.
|
||||||
|
self.app = websocket_session(endpoint)
|
||||||
|
else:
|
||||||
|
# Endpoint is a class. Treat it as ASGI.
|
||||||
|
self.app = endpoint
|
||||||
|
|
||||||
|
if middleware is not None:
|
||||||
|
for cls, args, kwargs in reversed(middleware):
|
||||||
|
self.app = cls(self.app, *args, **kwargs)
|
||||||
|
|
||||||
|
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
|
||||||
|
|
||||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||||
|
path_params: dict[str, Any]
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
route_path = get_route_path(scope)
|
||||||
|
match = self.path_regex.match(route_path)
|
||||||
|
if match:
|
||||||
|
matched_params = match.groupdict()
|
||||||
|
for key, value in matched_params.items():
|
||||||
|
matched_params[key] = self.param_convertors[key].convert(value)
|
||||||
|
path_params = dict(scope.get("path_params", {}))
|
||||||
|
path_params.update(matched_params)
|
||||||
|
child_scope = {"endpoint": self.endpoint, "path_params": path_params}
|
||||||
|
return Match.FULL, child_scope
|
||||||
|
return Match.NONE, {}
|
||||||
|
|
||||||
|
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||||
|
seen_params = set(path_params.keys())
|
||||||
|
expected_params = set(self.param_convertors.keys())
|
||||||
|
|
||||||
|
if name != self.name or seen_params != expected_params:
|
||||||
|
raise NoMatchFound(name, path_params)
|
||||||
|
|
||||||
|
path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
|
||||||
|
assert not remaining_params
|
||||||
|
return URLPath(path=path, protocol="websocket")
|
||||||
|
|
||||||
|
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return isinstance(other, WebSocketRoute) and self.path == other.path and self.endpoint == other.endpoint
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class Mount(BaseRoute):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
app: ASGIApp | None = None,
|
||||||
|
routes: Sequence[BaseRoute] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
*,
|
||||||
|
middleware: Sequence[Middleware] | None = None,
|
||||||
|
) -> None:
|
||||||
|
assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
|
||||||
|
assert app is not None or routes is not None, "Either 'app=...', or 'routes=' must be specified"
|
||||||
|
self.path = path.rstrip("/")
|
||||||
|
if app is not None:
|
||||||
|
self._base_app: ASGIApp = app
|
||||||
|
else:
|
||||||
|
self._base_app = Router(routes=routes)
|
||||||
|
self.app = self._base_app
|
||||||
|
if middleware is not None:
|
||||||
|
for cls, args, kwargs in reversed(middleware):
|
||||||
|
self.app = cls(self.app, *args, **kwargs)
|
||||||
|
self.name = name
|
||||||
|
self.path_regex, self.path_format, self.param_convertors = compile_path(self.path + "/{path:path}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routes(self) -> list[BaseRoute]:
|
||||||
|
return getattr(self._base_app, "routes", [])
|
||||||
|
|
||||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||||
|
path_params: dict[str, Any]
|
||||||
|
if scope["type"] in ("http", "websocket"): # pragma: no branch
|
||||||
|
root_path = scope.get("root_path", "")
|
||||||
|
route_path = get_route_path(scope)
|
||||||
|
match = self.path_regex.match(route_path)
|
||||||
|
if match:
|
||||||
|
matched_params = match.groupdict()
|
||||||
|
for key, value in matched_params.items():
|
||||||
|
matched_params[key] = self.param_convertors[key].convert(value)
|
||||||
|
remaining_path = "/" + matched_params.pop("path")
|
||||||
|
matched_path = route_path[: -len(remaining_path)]
|
||||||
|
path_params = dict(scope.get("path_params", {}))
|
||||||
|
path_params.update(matched_params)
|
||||||
|
child_scope = {
|
||||||
|
"path_params": path_params,
|
||||||
|
# app_root_path will only be set at the top level scope,
|
||||||
|
# initialized with the (optional) value of a root_path
|
||||||
|
# set above/before Starlette. And even though any
|
||||||
|
# mount will have its own child scope with its own respective
|
||||||
|
# root_path, the app_root_path will always be available in all
|
||||||
|
# the child scopes with the same top level value because it's
|
||||||
|
# set only once here with a default, any other child scope will
|
||||||
|
# just inherit that app_root_path default value stored in the
|
||||||
|
# scope. All this is needed to support Request.url_for(), as it
|
||||||
|
# uses the app_root_path to build the URL path.
|
||||||
|
"app_root_path": scope.get("app_root_path", root_path),
|
||||||
|
"root_path": root_path + matched_path,
|
||||||
|
"endpoint": self.app,
|
||||||
|
}
|
||||||
|
return Match.FULL, child_scope
|
||||||
|
return Match.NONE, {}
|
||||||
|
|
||||||
|
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||||
|
if self.name is not None and name == self.name and "path" in path_params:
|
||||||
|
# 'name' matches "<mount_name>".
|
||||||
|
path_params["path"] = path_params["path"].lstrip("/")
|
||||||
|
path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
|
||||||
|
if not remaining_params:
|
||||||
|
return URLPath(path=path)
|
||||||
|
elif self.name is None or name.startswith(self.name + ":"):
|
||||||
|
if self.name is None:
|
||||||
|
# No mount name.
|
||||||
|
remaining_name = name
|
||||||
|
else:
|
||||||
|
# 'name' matches "<mount_name>:<child_name>".
|
||||||
|
remaining_name = name[len(self.name) + 1 :]
|
||||||
|
path_kwarg = path_params.get("path")
|
||||||
|
path_params["path"] = ""
|
||||||
|
path_prefix, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
|
||||||
|
if path_kwarg is not None:
|
||||||
|
remaining_params["path"] = path_kwarg
|
||||||
|
for route in self.routes or []:
|
||||||
|
try:
|
||||||
|
url = route.url_path_for(remaining_name, **remaining_params)
|
||||||
|
return URLPath(path=path_prefix.rstrip("/") + str(url), protocol=url.protocol)
|
||||||
|
except NoMatchFound:
|
||||||
|
pass
|
||||||
|
raise NoMatchFound(name, path_params)
|
||||||
|
|
||||||
|
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return isinstance(other, Mount) and self.path == other.path and self.app == other.app
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
class_name = self.__class__.__name__
|
||||||
|
name = self.name or ""
|
||||||
|
return f"{class_name}(path={self.path!r}, name={name!r}, app={self.app!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class Host(BaseRoute):
|
||||||
|
def __init__(self, host: str, app: ASGIApp, name: str | None = None) -> None:
|
||||||
|
assert not host.startswith("/"), "Host must not start with '/'"
|
||||||
|
self.host = host
|
||||||
|
self.app = app
|
||||||
|
self.name = name
|
||||||
|
self.host_regex, self.host_format, self.param_convertors = compile_path(host)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routes(self) -> list[BaseRoute]:
|
||||||
|
return getattr(self.app, "routes", [])
|
||||||
|
|
||||||
|
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||||
|
if scope["type"] in ("http", "websocket"): # pragma:no branch
|
||||||
|
headers = Headers(scope=scope)
|
||||||
|
host = headers.get("host", "").split(":")[0]
|
||||||
|
match = self.host_regex.match(host)
|
||||||
|
if match:
|
||||||
|
matched_params = match.groupdict()
|
||||||
|
for key, value in matched_params.items():
|
||||||
|
matched_params[key] = self.param_convertors[key].convert(value)
|
||||||
|
path_params = dict(scope.get("path_params", {}))
|
||||||
|
path_params.update(matched_params)
|
||||||
|
child_scope = {"path_params": path_params, "endpoint": self.app}
|
||||||
|
return Match.FULL, child_scope
|
||||||
|
return Match.NONE, {}
|
||||||
|
|
||||||
|
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||||
|
if self.name is not None and name == self.name and "path" in path_params:
|
||||||
|
# 'name' matches "<mount_name>".
|
||||||
|
path = path_params.pop("path")
|
||||||
|
host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
|
||||||
|
if not remaining_params:
|
||||||
|
return URLPath(path=path, host=host)
|
||||||
|
elif self.name is None or name.startswith(self.name + ":"):
|
||||||
|
if self.name is None:
|
||||||
|
# No mount name.
|
||||||
|
remaining_name = name
|
||||||
|
else:
|
||||||
|
# 'name' matches "<mount_name>:<child_name>".
|
||||||
|
remaining_name = name[len(self.name) + 1 :]
|
||||||
|
host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
|
||||||
|
for route in self.routes or []:
|
||||||
|
try:
|
||||||
|
url = route.url_path_for(remaining_name, **remaining_params)
|
||||||
|
return URLPath(path=str(url), protocol=url.protocol, host=host)
|
||||||
|
except NoMatchFound:
|
||||||
|
pass
|
||||||
|
raise NoMatchFound(name, path_params)
|
||||||
|
|
||||||
|
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return isinstance(other, Host) and self.host == other.host and self.app == other.app
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
class_name = self.__class__.__name__
|
||||||
|
name = self.name or ""
|
||||||
|
return f"{class_name}(host={self.host!r}, name={name!r}, app={self.app!r})"
|
||||||
|
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncLiftContextManager(AbstractAsyncContextManager[_T]):
|
||||||
|
def __init__(self, cm: AbstractContextManager[_T]):
|
||||||
|
self._cm = cm
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _T:
|
||||||
|
return self._cm.__enter__()
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_value: BaseException | None,
|
||||||
|
traceback: types.TracebackType | None,
|
||||||
|
) -> bool | None:
|
||||||
|
return self._cm.__exit__(exc_type, exc_value, traceback)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_gen_lifespan_context(
|
||||||
|
lifespan_context: Callable[[Any], Generator[Any, Any, Any]],
|
||||||
|
) -> Callable[[Any], AbstractAsyncContextManager[Any]]:
|
||||||
|
cmgr = contextlib.contextmanager(lifespan_context)
|
||||||
|
|
||||||
|
@functools.wraps(cmgr)
|
||||||
|
def wrapper(app: Any) -> _AsyncLiftContextManager[Any]:
|
||||||
|
return _AsyncLiftContextManager(cmgr(app))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class _DefaultLifespan:
|
||||||
|
def __init__(self, router: Router):
|
||||||
|
self._router = router
|
||||||
|
|
||||||
|
async def __aenter__(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __call__(self: _T, app: object) -> _T:
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class Router:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
routes: Sequence[BaseRoute] | None = None,
|
||||||
|
redirect_slashes: bool = True,
|
||||||
|
default: ASGIApp | None = None,
|
||||||
|
# the generic to Lifespan[AppType] is the type of the top level application
|
||||||
|
# which the router cannot know statically, so we use Any
|
||||||
|
lifespan: Lifespan[Any] | None = None,
|
||||||
|
*,
|
||||||
|
middleware: Sequence[Middleware] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.routes = [] if routes is None else list(routes)
|
||||||
|
self.redirect_slashes = redirect_slashes
|
||||||
|
self.default = self.not_found if default is None else default
|
||||||
|
|
||||||
|
if lifespan is None:
|
||||||
|
self.lifespan_context: Lifespan[Any] = _DefaultLifespan(self)
|
||||||
|
|
||||||
|
elif inspect.isasyncgenfunction(lifespan):
|
||||||
|
warnings.warn(
|
||||||
|
"async generator function lifespans are deprecated, "
|
||||||
|
"use an @contextlib.asynccontextmanager function instead",
|
||||||
|
StarletteDeprecationWarning,
|
||||||
|
)
|
||||||
|
self.lifespan_context = asynccontextmanager(lifespan)
|
||||||
|
elif inspect.isgeneratorfunction(lifespan):
|
||||||
|
warnings.warn(
|
||||||
|
"generator function lifespans are deprecated, use an @contextlib.asynccontextmanager function instead",
|
||||||
|
StarletteDeprecationWarning,
|
||||||
|
)
|
||||||
|
self.lifespan_context = _wrap_gen_lifespan_context(lifespan)
|
||||||
|
else:
|
||||||
|
self.lifespan_context = lifespan
|
||||||
|
|
||||||
|
self.middleware_stack = self.app
|
||||||
|
if middleware:
|
||||||
|
for cls, args, kwargs in reversed(middleware):
|
||||||
|
self.middleware_stack = cls(self.middleware_stack, *args, **kwargs)
|
||||||
|
|
||||||
|
async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
websocket_close = WebSocketClose()
|
||||||
|
await websocket_close(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
# If we're running inside a starlette application then raise an
|
||||||
|
# exception, so that the configurable exception handler can deal with
|
||||||
|
# returning the response. For plain ASGI apps, just return the response.
|
||||||
|
if "app" in scope:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
else:
|
||||||
|
response = PlainTextResponse("Not Found", status_code=404)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
|
||||||
|
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
|
||||||
|
for route in self.routes:
|
||||||
|
try:
|
||||||
|
return route.url_path_for(name, **path_params)
|
||||||
|
except NoMatchFound:
|
||||||
|
pass
|
||||||
|
raise NoMatchFound(name, path_params)
|
||||||
|
|
||||||
|
async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
"""
|
||||||
|
Handle ASGI lifespan messages, which allows us to manage application
|
||||||
|
startup and shutdown events.
|
||||||
|
"""
|
||||||
|
started = False
|
||||||
|
app: Any = scope.get("app")
|
||||||
|
await receive()
|
||||||
|
try:
|
||||||
|
async with self.lifespan_context(app) as maybe_state:
|
||||||
|
if maybe_state is not None:
|
||||||
|
if "state" not in scope:
|
||||||
|
raise RuntimeError('The server does not support "state" in the lifespan scope.')
|
||||||
|
scope["state"].update(maybe_state)
|
||||||
|
await send({"type": "lifespan.startup.complete"})
|
||||||
|
started = True
|
||||||
|
await receive()
|
||||||
|
except BaseException:
|
||||||
|
exc_text = traceback.format_exc()
|
||||||
|
if started:
|
||||||
|
await send({"type": "lifespan.shutdown.failed", "message": exc_text})
|
||||||
|
else:
|
||||||
|
await send({"type": "lifespan.startup.failed", "message": exc_text})
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
await send({"type": "lifespan.shutdown.complete"})
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
"""
|
||||||
|
The main entry point to the Router class.
|
||||||
|
"""
|
||||||
|
await self.middleware_stack(scope, receive, send)
|
||||||
|
|
||||||
|
async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
assert scope["type"] in ("http", "websocket", "lifespan")
|
||||||
|
|
||||||
|
if "router" not in scope:
|
||||||
|
scope["router"] = self
|
||||||
|
|
||||||
|
if scope["type"] == "lifespan":
|
||||||
|
await self.lifespan(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
partial = None
|
||||||
|
|
||||||
|
for route in self.routes:
|
||||||
|
# Determine if any route matches the incoming scope,
|
||||||
|
# and hand over to the matching route if found.
|
||||||
|
match, child_scope = route.matches(scope)
|
||||||
|
if match == Match.FULL:
|
||||||
|
scope.update(child_scope)
|
||||||
|
await route.handle(scope, receive, send)
|
||||||
|
return
|
||||||
|
elif match == Match.PARTIAL and partial is None:
|
||||||
|
partial = route
|
||||||
|
partial_scope = child_scope
|
||||||
|
|
||||||
|
if partial is not None:
|
||||||
|
# Handle partial matches. These are cases where an endpoint is
|
||||||
|
# able to handle the request, but is not a preferred option.
|
||||||
|
# We use this in particular to deal with "405 Method Not Allowed".
|
||||||
|
scope.update(partial_scope)
|
||||||
|
await partial.handle(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
route_path = get_route_path(scope)
|
||||||
|
if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
|
||||||
|
redirect_scope = dict(scope)
|
||||||
|
if route_path.endswith("/"):
|
||||||
|
redirect_scope["path"] = redirect_scope["path"].rstrip("/")
|
||||||
|
else:
|
||||||
|
redirect_scope["path"] = redirect_scope["path"] + "/"
|
||||||
|
|
||||||
|
for route in self.routes:
|
||||||
|
match, child_scope = route.matches(redirect_scope)
|
||||||
|
if match != Match.NONE:
|
||||||
|
redirect_url = URL(scope=redirect_scope)
|
||||||
|
response = RedirectResponse(url=str(redirect_url))
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.default(scope, receive, send)
|
||||||
|
|
||||||
|
def __eq__(self, other: Any) -> bool:
|
||||||
|
return isinstance(other, Router) and self.routes == other.routes
|
||||||
|
|
||||||
|
def mount(self, path: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
|
||||||
|
route = Mount(path, app=app, name=name)
|
||||||
|
self.routes.append(route)
|
||||||
|
|
||||||
|
def host(self, host: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
|
||||||
|
route = Host(host, app=app, name=name)
|
||||||
|
self.routes.append(route)
|
||||||
|
|
||||||
|
def add_route(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
endpoint: Callable[[Request], Awaitable[Response] | Response],
|
||||||
|
methods: Collection[str] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
include_in_schema: bool = True,
|
||||||
|
) -> None: # pragma: no cover
|
||||||
|
route = Route(
|
||||||
|
path,
|
||||||
|
endpoint=endpoint,
|
||||||
|
methods=methods,
|
||||||
|
name=name,
|
||||||
|
include_in_schema=include_in_schema,
|
||||||
|
)
|
||||||
|
self.routes.append(route)
|
||||||
|
|
||||||
|
def add_websocket_route(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
endpoint: Callable[[WebSocket], Awaitable[None]],
|
||||||
|
name: str | None = None,
|
||||||
|
) -> None: # pragma: no cover
|
||||||
|
route = WebSocketRoute(path, endpoint=endpoint, name=name)
|
||||||
|
self.routes.append(route)
|
||||||
148
venv/Lib/site-packages/starlette/schemas.py
Normal file
148
venv/Lib/site-packages/starlette/schemas.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
from starlette.routing import BaseRoute, Host, Mount, Route
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
|
yaml = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIResponse(Response):
|
||||||
|
media_type = "application/vnd.oai.openapi"
|
||||||
|
|
||||||
|
def render(self, content: Any) -> bytes:
|
||||||
|
assert yaml is not None, "`pyyaml` must be installed to use OpenAPIResponse."
|
||||||
|
assert isinstance(content, dict), "The schema passed to OpenAPIResponse should be a dictionary."
|
||||||
|
return yaml.dump(content, default_flow_style=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointInfo(NamedTuple):
|
||||||
|
path: str
|
||||||
|
http_method: str
|
||||||
|
func: Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
_remove_converter_pattern = re.compile(r":\w+}")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSchemaGenerator:
|
||||||
|
def get_schema(self, routes: list[BaseRoute]) -> dict[str, Any]:
|
||||||
|
raise NotImplementedError() # pragma: no cover
|
||||||
|
|
||||||
|
def get_endpoints(self, routes: list[BaseRoute]) -> list[EndpointInfo]:
|
||||||
|
"""
|
||||||
|
Given the routes, yields the following information:
|
||||||
|
|
||||||
|
- path
|
||||||
|
eg: /users/
|
||||||
|
- http_method
|
||||||
|
one of 'get', 'post', 'put', 'patch', 'delete', 'options'
|
||||||
|
- func
|
||||||
|
method ready to extract the docstring
|
||||||
|
"""
|
||||||
|
endpoints_info: list[EndpointInfo] = []
|
||||||
|
|
||||||
|
for route in routes:
|
||||||
|
if isinstance(route, Mount | Host):
|
||||||
|
routes = route.routes or []
|
||||||
|
if isinstance(route, Mount):
|
||||||
|
path = self._remove_converter(route.path)
|
||||||
|
else:
|
||||||
|
path = ""
|
||||||
|
sub_endpoints = [
|
||||||
|
EndpointInfo(
|
||||||
|
path="".join((path, sub_endpoint.path)),
|
||||||
|
http_method=sub_endpoint.http_method,
|
||||||
|
func=sub_endpoint.func,
|
||||||
|
)
|
||||||
|
for sub_endpoint in self.get_endpoints(routes)
|
||||||
|
]
|
||||||
|
endpoints_info.extend(sub_endpoints)
|
||||||
|
|
||||||
|
elif not isinstance(route, Route) or not route.include_in_schema:
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif inspect.isfunction(route.endpoint) or inspect.ismethod(route.endpoint):
|
||||||
|
path = self._remove_converter(route.path)
|
||||||
|
for method in route.methods or ["GET"]:
|
||||||
|
if method == "HEAD":
|
||||||
|
continue
|
||||||
|
endpoints_info.append(EndpointInfo(path, method.lower(), route.endpoint))
|
||||||
|
else:
|
||||||
|
path = self._remove_converter(route.path)
|
||||||
|
for method in ["get", "post", "put", "patch", "delete", "options"]:
|
||||||
|
if not hasattr(route.endpoint, method):
|
||||||
|
continue
|
||||||
|
func = getattr(route.endpoint, method)
|
||||||
|
endpoints_info.append(EndpointInfo(path, method.lower(), func))
|
||||||
|
|
||||||
|
return endpoints_info
|
||||||
|
|
||||||
|
def _remove_converter(self, path: str) -> str:
|
||||||
|
"""
|
||||||
|
Remove the converter from the path.
|
||||||
|
For example, a route like this:
|
||||||
|
Route("/users/{id:int}", endpoint=get_user, methods=["GET"])
|
||||||
|
Should be represented as `/users/{id}` in the OpenAPI schema.
|
||||||
|
"""
|
||||||
|
return _remove_converter_pattern.sub("}", path)
|
||||||
|
|
||||||
|
def parse_docstring(self, func_or_method: Callable[..., Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Given a function, parse the docstring as YAML and return a dictionary of info.
|
||||||
|
"""
|
||||||
|
docstring = func_or_method.__doc__
|
||||||
|
if not docstring:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
assert yaml is not None, "`pyyaml` must be installed to use parse_docstring."
|
||||||
|
|
||||||
|
# We support having regular docstrings before the schema
|
||||||
|
# definition. Here we return just the schema part from
|
||||||
|
# the docstring.
|
||||||
|
docstring = docstring.split("---")[-1]
|
||||||
|
|
||||||
|
parsed = yaml.safe_load(docstring)
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
# A regular docstring (not yaml formatted) can return
|
||||||
|
# a simple string here, which wouldn't follow the schema.
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def OpenAPIResponse(self, request: Request) -> Response:
|
||||||
|
routes = request.app.routes
|
||||||
|
schema = self.get_schema(routes=routes)
|
||||||
|
return OpenAPIResponse(schema)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaGenerator(BaseSchemaGenerator):
|
||||||
|
def __init__(self, base_schema: dict[str, Any]) -> None:
|
||||||
|
self.base_schema = base_schema
|
||||||
|
|
||||||
|
def get_schema(self, routes: list[BaseRoute]) -> dict[str, Any]:
|
||||||
|
schema = dict(self.base_schema)
|
||||||
|
schema.setdefault("paths", {})
|
||||||
|
endpoints_info = self.get_endpoints(routes)
|
||||||
|
|
||||||
|
for endpoint in endpoints_info:
|
||||||
|
parsed = self.parse_docstring(endpoint.func)
|
||||||
|
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if endpoint.path not in schema["paths"]:
|
||||||
|
schema["paths"][endpoint.path] = {}
|
||||||
|
|
||||||
|
schema["paths"][endpoint.path][endpoint.http_method] = parsed
|
||||||
|
|
||||||
|
return schema
|
||||||
223
venv/Lib/site-packages/starlette/staticfiles.py
Normal file
223
venv/Lib/site-packages/starlette/staticfiles.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from email.utils import parsedate
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import anyio.to_thread
|
||||||
|
|
||||||
|
from starlette._utils import get_route_path
|
||||||
|
from starlette.datastructures import URL, Headers
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import FileResponse, RedirectResponse, Response
|
||||||
|
from starlette.types import Receive, Scope, Send
|
||||||
|
|
||||||
|
PathLike = Union[str, "os.PathLike[str]"]
|
||||||
|
|
||||||
|
|
||||||
|
class NotModifiedResponse(Response):
|
||||||
|
NOT_MODIFIED_HEADERS = (
|
||||||
|
"cache-control",
|
||||||
|
"content-location",
|
||||||
|
"date",
|
||||||
|
"etag",
|
||||||
|
"expires",
|
||||||
|
"vary",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, headers: Headers):
|
||||||
|
super().__init__(
|
||||||
|
status_code=304,
|
||||||
|
headers={name: value for name, value in headers.items() if name in self.NOT_MODIFIED_HEADERS},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticFiles:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
directory: PathLike | None = None,
|
||||||
|
packages: list[str | tuple[str, str]] | None = None,
|
||||||
|
html: bool = False,
|
||||||
|
check_dir: bool = True,
|
||||||
|
follow_symlink: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.directory = directory
|
||||||
|
self.packages = packages
|
||||||
|
self.all_directories = self.get_directories(directory, packages)
|
||||||
|
self.html = html
|
||||||
|
self.config_checked = False
|
||||||
|
self.follow_symlink = follow_symlink
|
||||||
|
if check_dir and directory is not None and not os.path.isdir(directory):
|
||||||
|
raise RuntimeError(f"Directory '{directory}' does not exist")
|
||||||
|
|
||||||
|
def get_directories(
|
||||||
|
self,
|
||||||
|
directory: PathLike | None = None,
|
||||||
|
packages: list[str | tuple[str, str]] | None = None,
|
||||||
|
) -> list[PathLike]:
|
||||||
|
"""
|
||||||
|
Given `directory` and `packages` arguments, return a list of all the
|
||||||
|
directories that should be used for serving static files from.
|
||||||
|
"""
|
||||||
|
directories = []
|
||||||
|
if directory is not None:
|
||||||
|
directories.append(directory)
|
||||||
|
|
||||||
|
for package in packages or []:
|
||||||
|
if isinstance(package, tuple):
|
||||||
|
package, statics_dir = package
|
||||||
|
else:
|
||||||
|
statics_dir = "statics"
|
||||||
|
spec = importlib.util.find_spec(package)
|
||||||
|
assert spec is not None, f"Package {package!r} could not be found."
|
||||||
|
assert spec.origin is not None, f"Package {package!r} could not be found."
|
||||||
|
package_directory = os.path.normpath(os.path.join(spec.origin, "..", statics_dir))
|
||||||
|
assert os.path.isdir(package_directory), (
|
||||||
|
f"Directory '{statics_dir!r}' in package {package!r} could not be found."
|
||||||
|
)
|
||||||
|
directories.append(package_directory)
|
||||||
|
|
||||||
|
return directories
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
"""
|
||||||
|
The ASGI entry point.
|
||||||
|
"""
|
||||||
|
assert scope["type"] == "http"
|
||||||
|
|
||||||
|
if not self.config_checked:
|
||||||
|
await self.check_config()
|
||||||
|
self.config_checked = True
|
||||||
|
|
||||||
|
path = self.get_path(scope)
|
||||||
|
response = await self.get_response(path, scope)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
|
||||||
|
def get_path(self, scope: Scope) -> str:
|
||||||
|
"""
|
||||||
|
Given the ASGI scope, return the `path` string to serve up,
|
||||||
|
with OS specific path separators, and any '..', '.' components removed.
|
||||||
|
"""
|
||||||
|
route_path = get_route_path(scope)
|
||||||
|
return os.path.normpath(os.path.join(*route_path.split("/")))
|
||||||
|
|
||||||
|
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||||
|
"""
|
||||||
|
Returns an HTTP response, given the incoming path, method and request headers.
|
||||||
|
"""
|
||||||
|
if scope["method"] not in ("GET", "HEAD"):
|
||||||
|
raise HTTPException(status_code=405)
|
||||||
|
|
||||||
|
try:
|
||||||
|
full_path, stat_result = await anyio.to_thread.run_sync(self.lookup_path, path)
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
except OSError as exc:
|
||||||
|
# Filename is too long, so it can't be a valid static file.
|
||||||
|
if exc.errno == errno.ENAMETOOLONG:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
raise exc
|
||||||
|
except ValueError:
|
||||||
|
# Null bytes or other invalid characters in the path.
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
if stat_result and stat.S_ISREG(stat_result.st_mode):
|
||||||
|
# We have a static file to serve.
|
||||||
|
return self.file_response(full_path, stat_result, scope)
|
||||||
|
|
||||||
|
elif stat_result and stat.S_ISDIR(stat_result.st_mode) and self.html:
|
||||||
|
# We're in HTML mode, and have got a directory URL.
|
||||||
|
# Check if we have 'index.html' file to serve.
|
||||||
|
index_path = os.path.join(path, "index.html")
|
||||||
|
full_path, stat_result = await anyio.to_thread.run_sync(self.lookup_path, index_path)
|
||||||
|
if stat_result is not None and stat.S_ISREG(stat_result.st_mode):
|
||||||
|
if not scope["path"].endswith("/"):
|
||||||
|
# Directory URLs should redirect to always end in "/".
|
||||||
|
url = URL(scope=scope)
|
||||||
|
url = url.replace(path=url.path + "/")
|
||||||
|
return RedirectResponse(url=url)
|
||||||
|
return self.file_response(full_path, stat_result, scope)
|
||||||
|
|
||||||
|
if self.html:
|
||||||
|
# Check for '404.html' if we're in HTML mode.
|
||||||
|
full_path, stat_result = await anyio.to_thread.run_sync(self.lookup_path, "404.html")
|
||||||
|
if stat_result and stat.S_ISREG(stat_result.st_mode):
|
||||||
|
return FileResponse(full_path, stat_result=stat_result, status_code=404)
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
def lookup_path(self, path: str) -> tuple[str, os.stat_result | None]:
|
||||||
|
# Reject absolute paths so they cannot escape the served directory.
|
||||||
|
if path.startswith(("/", "\\")):
|
||||||
|
return "", None
|
||||||
|
for directory in self.all_directories:
|
||||||
|
joined_path = os.path.join(directory, path)
|
||||||
|
if self.follow_symlink:
|
||||||
|
full_path = os.path.abspath(joined_path)
|
||||||
|
directory = os.path.abspath(directory)
|
||||||
|
else:
|
||||||
|
full_path = os.path.realpath(joined_path)
|
||||||
|
directory = os.path.realpath(directory)
|
||||||
|
if os.path.commonpath([full_path, directory]) != str(directory):
|
||||||
|
# Don't allow misbehaving clients to break out of the static files directory.
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return full_path, os.stat(full_path)
|
||||||
|
except (FileNotFoundError, NotADirectoryError):
|
||||||
|
continue
|
||||||
|
return "", None
|
||||||
|
|
||||||
|
def file_response(
|
||||||
|
self,
|
||||||
|
full_path: PathLike,
|
||||||
|
stat_result: os.stat_result,
|
||||||
|
scope: Scope,
|
||||||
|
status_code: int = 200,
|
||||||
|
) -> Response:
|
||||||
|
request_headers = Headers(scope=scope)
|
||||||
|
|
||||||
|
response = FileResponse(full_path, status_code=status_code, stat_result=stat_result)
|
||||||
|
if self.is_not_modified(response.headers, request_headers):
|
||||||
|
return NotModifiedResponse(response.headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def check_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Perform a one-off configuration check that StaticFiles is actually
|
||||||
|
pointed at a directory, so that we can raise loud errors rather than
|
||||||
|
just returning 404 responses.
|
||||||
|
"""
|
||||||
|
if self.directory is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat_result = await anyio.to_thread.run_sync(os.stat, self.directory)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise RuntimeError(f"StaticFiles directory '{self.directory}' does not exist.")
|
||||||
|
if not (stat.S_ISDIR(stat_result.st_mode) or stat.S_ISLNK(stat_result.st_mode)):
|
||||||
|
raise RuntimeError(f"StaticFiles path '{self.directory}' is not a directory.")
|
||||||
|
|
||||||
|
def is_not_modified(self, response_headers: Headers, request_headers: Headers) -> bool:
|
||||||
|
"""
|
||||||
|
Given the request and response headers, return `True` if an HTTP
|
||||||
|
"Not Modified" response could be returned instead.
|
||||||
|
"""
|
||||||
|
if if_none_match := request_headers.get("if-none-match"):
|
||||||
|
# The "etag" header is added by FileResponse, so it's always present.
|
||||||
|
etag = response_headers["etag"]
|
||||||
|
return etag in [tag.strip().removeprefix("W/") for tag in if_none_match.split(",")]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if_modified_since = parsedate(request_headers["if-modified-since"])
|
||||||
|
last_modified = parsedate(response_headers["last-modified"])
|
||||||
|
if if_modified_since is not None and last_modified is not None and if_modified_since >= last_modified:
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
211
venv/Lib/site-packages/starlette/status.py
Normal file
211
venv/Lib/site-packages/starlette/status.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
HTTP codes
|
||||||
|
See HTTP Status Code Registry:
|
||||||
|
https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
|
||||||
|
|
||||||
|
And RFC 9110 - https://www.rfc-editor.org/rfc/rfc9110
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from starlette.exceptions import StarletteDeprecationWarning
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HTTP_100_CONTINUE",
|
||||||
|
"HTTP_101_SWITCHING_PROTOCOLS",
|
||||||
|
"HTTP_102_PROCESSING",
|
||||||
|
"HTTP_103_EARLY_HINTS",
|
||||||
|
"HTTP_200_OK",
|
||||||
|
"HTTP_201_CREATED",
|
||||||
|
"HTTP_202_ACCEPTED",
|
||||||
|
"HTTP_203_NON_AUTHORITATIVE_INFORMATION",
|
||||||
|
"HTTP_204_NO_CONTENT",
|
||||||
|
"HTTP_205_RESET_CONTENT",
|
||||||
|
"HTTP_206_PARTIAL_CONTENT",
|
||||||
|
"HTTP_207_MULTI_STATUS",
|
||||||
|
"HTTP_208_ALREADY_REPORTED",
|
||||||
|
"HTTP_226_IM_USED",
|
||||||
|
"HTTP_300_MULTIPLE_CHOICES",
|
||||||
|
"HTTP_301_MOVED_PERMANENTLY",
|
||||||
|
"HTTP_302_FOUND",
|
||||||
|
"HTTP_303_SEE_OTHER",
|
||||||
|
"HTTP_304_NOT_MODIFIED",
|
||||||
|
"HTTP_305_USE_PROXY",
|
||||||
|
"HTTP_306_RESERVED",
|
||||||
|
"HTTP_307_TEMPORARY_REDIRECT",
|
||||||
|
"HTTP_308_PERMANENT_REDIRECT",
|
||||||
|
"HTTP_400_BAD_REQUEST",
|
||||||
|
"HTTP_401_UNAUTHORIZED",
|
||||||
|
"HTTP_402_PAYMENT_REQUIRED",
|
||||||
|
"HTTP_403_FORBIDDEN",
|
||||||
|
"HTTP_404_NOT_FOUND",
|
||||||
|
"HTTP_405_METHOD_NOT_ALLOWED",
|
||||||
|
"HTTP_406_NOT_ACCEPTABLE",
|
||||||
|
"HTTP_407_PROXY_AUTHENTICATION_REQUIRED",
|
||||||
|
"HTTP_408_REQUEST_TIMEOUT",
|
||||||
|
"HTTP_409_CONFLICT",
|
||||||
|
"HTTP_410_GONE",
|
||||||
|
"HTTP_411_LENGTH_REQUIRED",
|
||||||
|
"HTTP_412_PRECONDITION_FAILED",
|
||||||
|
"HTTP_413_CONTENT_TOO_LARGE",
|
||||||
|
"HTTP_414_URI_TOO_LONG",
|
||||||
|
"HTTP_415_UNSUPPORTED_MEDIA_TYPE",
|
||||||
|
"HTTP_416_RANGE_NOT_SATISFIABLE",
|
||||||
|
"HTTP_417_EXPECTATION_FAILED",
|
||||||
|
"HTTP_418_IM_A_TEAPOT",
|
||||||
|
"HTTP_421_MISDIRECTED_REQUEST",
|
||||||
|
"HTTP_422_UNPROCESSABLE_CONTENT",
|
||||||
|
"HTTP_423_LOCKED",
|
||||||
|
"HTTP_424_FAILED_DEPENDENCY",
|
||||||
|
"HTTP_425_TOO_EARLY",
|
||||||
|
"HTTP_426_UPGRADE_REQUIRED",
|
||||||
|
"HTTP_428_PRECONDITION_REQUIRED",
|
||||||
|
"HTTP_429_TOO_MANY_REQUESTS",
|
||||||
|
"HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE",
|
||||||
|
"HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS",
|
||||||
|
"HTTP_500_INTERNAL_SERVER_ERROR",
|
||||||
|
"HTTP_501_NOT_IMPLEMENTED",
|
||||||
|
"HTTP_502_BAD_GATEWAY",
|
||||||
|
"HTTP_503_SERVICE_UNAVAILABLE",
|
||||||
|
"HTTP_504_GATEWAY_TIMEOUT",
|
||||||
|
"HTTP_505_HTTP_VERSION_NOT_SUPPORTED",
|
||||||
|
"HTTP_506_VARIANT_ALSO_NEGOTIATES",
|
||||||
|
"HTTP_507_INSUFFICIENT_STORAGE",
|
||||||
|
"HTTP_508_LOOP_DETECTED",
|
||||||
|
"HTTP_510_NOT_EXTENDED",
|
||||||
|
"HTTP_511_NETWORK_AUTHENTICATION_REQUIRED",
|
||||||
|
"WS_1000_NORMAL_CLOSURE",
|
||||||
|
"WS_1001_GOING_AWAY",
|
||||||
|
"WS_1002_PROTOCOL_ERROR",
|
||||||
|
"WS_1003_UNSUPPORTED_DATA",
|
||||||
|
"WS_1005_NO_STATUS_RCVD",
|
||||||
|
"WS_1006_ABNORMAL_CLOSURE",
|
||||||
|
"WS_1007_INVALID_FRAME_PAYLOAD_DATA",
|
||||||
|
"WS_1008_POLICY_VIOLATION",
|
||||||
|
"WS_1009_MESSAGE_TOO_BIG",
|
||||||
|
"WS_1010_MANDATORY_EXT",
|
||||||
|
"WS_1011_INTERNAL_ERROR",
|
||||||
|
"WS_1012_SERVICE_RESTART",
|
||||||
|
"WS_1013_TRY_AGAIN_LATER",
|
||||||
|
"WS_1014_BAD_GATEWAY",
|
||||||
|
"WS_1015_TLS_HANDSHAKE",
|
||||||
|
]
|
||||||
|
|
||||||
|
HTTP_100_CONTINUE = 100
|
||||||
|
HTTP_101_SWITCHING_PROTOCOLS = 101
|
||||||
|
HTTP_102_PROCESSING = 102
|
||||||
|
HTTP_103_EARLY_HINTS = 103
|
||||||
|
HTTP_200_OK = 200
|
||||||
|
HTTP_201_CREATED = 201
|
||||||
|
HTTP_202_ACCEPTED = 202
|
||||||
|
HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
|
||||||
|
HTTP_204_NO_CONTENT = 204
|
||||||
|
HTTP_205_RESET_CONTENT = 205
|
||||||
|
HTTP_206_PARTIAL_CONTENT = 206
|
||||||
|
HTTP_207_MULTI_STATUS = 207
|
||||||
|
HTTP_208_ALREADY_REPORTED = 208
|
||||||
|
HTTP_226_IM_USED = 226
|
||||||
|
HTTP_300_MULTIPLE_CHOICES = 300
|
||||||
|
HTTP_301_MOVED_PERMANENTLY = 301
|
||||||
|
HTTP_302_FOUND = 302
|
||||||
|
HTTP_303_SEE_OTHER = 303
|
||||||
|
HTTP_304_NOT_MODIFIED = 304
|
||||||
|
HTTP_305_USE_PROXY = 305
|
||||||
|
HTTP_306_RESERVED = 306
|
||||||
|
HTTP_307_TEMPORARY_REDIRECT = 307
|
||||||
|
HTTP_308_PERMANENT_REDIRECT = 308
|
||||||
|
HTTP_400_BAD_REQUEST = 400
|
||||||
|
HTTP_401_UNAUTHORIZED = 401
|
||||||
|
HTTP_402_PAYMENT_REQUIRED = 402
|
||||||
|
HTTP_403_FORBIDDEN = 403
|
||||||
|
HTTP_404_NOT_FOUND = 404
|
||||||
|
HTTP_405_METHOD_NOT_ALLOWED = 405
|
||||||
|
HTTP_406_NOT_ACCEPTABLE = 406
|
||||||
|
HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
|
||||||
|
HTTP_408_REQUEST_TIMEOUT = 408
|
||||||
|
HTTP_409_CONFLICT = 409
|
||||||
|
HTTP_410_GONE = 410
|
||||||
|
HTTP_411_LENGTH_REQUIRED = 411
|
||||||
|
HTTP_412_PRECONDITION_FAILED = 412
|
||||||
|
HTTP_413_CONTENT_TOO_LARGE = 413
|
||||||
|
HTTP_414_URI_TOO_LONG = 414
|
||||||
|
HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
|
||||||
|
HTTP_416_RANGE_NOT_SATISFIABLE = 416
|
||||||
|
HTTP_417_EXPECTATION_FAILED = 417
|
||||||
|
HTTP_418_IM_A_TEAPOT = 418
|
||||||
|
HTTP_421_MISDIRECTED_REQUEST = 421
|
||||||
|
HTTP_422_UNPROCESSABLE_CONTENT = 422
|
||||||
|
HTTP_423_LOCKED = 423
|
||||||
|
HTTP_424_FAILED_DEPENDENCY = 424
|
||||||
|
HTTP_425_TOO_EARLY = 425
|
||||||
|
HTTP_426_UPGRADE_REQUIRED = 426
|
||||||
|
HTTP_428_PRECONDITION_REQUIRED = 428
|
||||||
|
HTTP_429_TOO_MANY_REQUESTS = 429
|
||||||
|
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431
|
||||||
|
HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR = 500
|
||||||
|
HTTP_501_NOT_IMPLEMENTED = 501
|
||||||
|
HTTP_502_BAD_GATEWAY = 502
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE = 503
|
||||||
|
HTTP_504_GATEWAY_TIMEOUT = 504
|
||||||
|
HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
|
||||||
|
HTTP_506_VARIANT_ALSO_NEGOTIATES = 506
|
||||||
|
HTTP_507_INSUFFICIENT_STORAGE = 507
|
||||||
|
HTTP_508_LOOP_DETECTED = 508
|
||||||
|
HTTP_510_NOT_EXTENDED = 510
|
||||||
|
HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
WebSocket codes
|
||||||
|
https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
|
||||||
|
"""
|
||||||
|
WS_1000_NORMAL_CLOSURE = 1000
|
||||||
|
WS_1001_GOING_AWAY = 1001
|
||||||
|
WS_1002_PROTOCOL_ERROR = 1002
|
||||||
|
WS_1003_UNSUPPORTED_DATA = 1003
|
||||||
|
WS_1005_NO_STATUS_RCVD = 1005
|
||||||
|
WS_1006_ABNORMAL_CLOSURE = 1006
|
||||||
|
WS_1007_INVALID_FRAME_PAYLOAD_DATA = 1007
|
||||||
|
WS_1008_POLICY_VIOLATION = 1008
|
||||||
|
WS_1009_MESSAGE_TOO_BIG = 1009
|
||||||
|
WS_1010_MANDATORY_EXT = 1010
|
||||||
|
WS_1011_INTERNAL_ERROR = 1011
|
||||||
|
WS_1012_SERVICE_RESTART = 1012
|
||||||
|
WS_1013_TRY_AGAIN_LATER = 1013
|
||||||
|
WS_1014_BAD_GATEWAY = 1014
|
||||||
|
WS_1015_TLS_HANDSHAKE = 1015
|
||||||
|
|
||||||
|
__deprecated__ = {
|
||||||
|
"HTTP_413_REQUEST_ENTITY_TOO_LARGE": 413,
|
||||||
|
"HTTP_414_REQUEST_URI_TOO_LONG": 414,
|
||||||
|
"HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE": 416,
|
||||||
|
"HTTP_422_UNPROCESSABLE_ENTITY": 422,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> int:
|
||||||
|
deprecation_changes = {
|
||||||
|
"HTTP_413_REQUEST_ENTITY_TOO_LARGE": "HTTP_413_CONTENT_TOO_LARGE",
|
||||||
|
"HTTP_414_REQUEST_URI_TOO_LONG": "HTTP_414_URI_TOO_LONG",
|
||||||
|
"HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE": "HTTP_416_RANGE_NOT_SATISFIABLE",
|
||||||
|
"HTTP_422_UNPROCESSABLE_ENTITY": "HTTP_422_UNPROCESSABLE_CONTENT",
|
||||||
|
}
|
||||||
|
|
||||||
|
deprecated = __deprecated__.get(name)
|
||||||
|
if deprecated:
|
||||||
|
warnings.warn(
|
||||||
|
f"'{name}' is deprecated. Use '{deprecation_changes[name]}' instead.",
|
||||||
|
category=StarletteDeprecationWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
return deprecated
|
||||||
|
|
||||||
|
raise AttributeError(f"module 'starlette.status' has no attribute '{name}'")
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> list[str]:
|
||||||
|
return sorted(list(__all__) + list(__deprecated__.keys())) # pragma: no cover
|
||||||
Reference in New Issue
Block a user