Merge branch 'main' into accept-enhance
This commit is contained in:
commit
fd2e4819d1
|
@ -9,6 +9,7 @@ omit =
|
||||||
sanic/simple.py
|
sanic/simple.py
|
||||||
sanic/utils.py
|
sanic/utils.py
|
||||||
sanic/cli
|
sanic/cli
|
||||||
|
sanic/pages
|
||||||
|
|
||||||
[html]
|
[html]
|
||||||
directory = coverage
|
directory = coverage
|
||||||
|
|
|
@ -17,7 +17,8 @@ ignore:
|
||||||
- "sanic/compat.py"
|
- "sanic/compat.py"
|
||||||
- "sanic/simple.py"
|
- "sanic/simple.py"
|
||||||
- "sanic/utils.py"
|
- "sanic/utils.py"
|
||||||
- "sanic/cli"
|
- "sanic/cli/"
|
||||||
|
- "sanic/pages/"
|
||||||
- ".github/"
|
- ".github/"
|
||||||
- "changelogs/"
|
- "changelogs/"
|
||||||
- "docker/"
|
- "docker/"
|
||||||
|
|
|
@ -23,5 +23,6 @@ module = [
|
||||||
"trustme.*",
|
"trustme.*",
|
||||||
"sanic_routing.*",
|
"sanic_routing.*",
|
||||||
"aioquic.*",
|
"aioquic.*",
|
||||||
|
"html5tagger.*",
|
||||||
]
|
]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|
14
sanic/app.py
14
sanic/app.py
|
@ -72,6 +72,7 @@ from sanic.log import (
|
||||||
from sanic.middleware import Middleware, MiddlewareLocation
|
from sanic.middleware import Middleware, MiddlewareLocation
|
||||||
from sanic.mixins.listeners import ListenerEvent
|
from sanic.mixins.listeners import ListenerEvent
|
||||||
from sanic.mixins.startup import StartupMixin
|
from sanic.mixins.startup import StartupMixin
|
||||||
|
from sanic.mixins.static import StaticHandleMixin
|
||||||
from sanic.models.futures import (
|
from sanic.models.futures import (
|
||||||
FutureException,
|
FutureException,
|
||||||
FutureListener,
|
FutureListener,
|
||||||
|
@ -79,7 +80,6 @@ from sanic.models.futures import (
|
||||||
FutureRegistry,
|
FutureRegistry,
|
||||||
FutureRoute,
|
FutureRoute,
|
||||||
FutureSignal,
|
FutureSignal,
|
||||||
FutureStatic,
|
|
||||||
)
|
)
|
||||||
from sanic.models.handler_types import ListenerType, MiddlewareType
|
from sanic.models.handler_types import ListenerType, MiddlewareType
|
||||||
from sanic.models.handler_types import Sanic as SanicVar
|
from sanic.models.handler_types import Sanic as SanicVar
|
||||||
|
@ -106,7 +106,7 @@ if OS_IS_WINDOWS: # no cov
|
||||||
enable_windows_color_support()
|
enable_windows_color_support()
|
||||||
|
|
||||||
|
|
||||||
class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||||
"""
|
"""
|
||||||
The main application instance
|
The main application instance
|
||||||
"""
|
"""
|
||||||
|
@ -441,9 +441,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||||
|
|
||||||
return routes
|
return routes
|
||||||
|
|
||||||
def _apply_static(self, static: FutureStatic) -> Route:
|
|
||||||
return self._register_static(static)
|
|
||||||
|
|
||||||
def _apply_middleware(
|
def _apply_middleware(
|
||||||
self,
|
self,
|
||||||
middleware: FutureMiddleware,
|
middleware: FutureMiddleware,
|
||||||
|
@ -890,11 +887,11 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||||
Union[
|
Union[
|
||||||
BaseHTTPResponse,
|
BaseHTTPResponse,
|
||||||
Coroutine[Any, Any, Optional[BaseHTTPResponse]],
|
Coroutine[Any, Any, Optional[BaseHTTPResponse]],
|
||||||
|
ResponseStream,
|
||||||
]
|
]
|
||||||
] = None
|
] = None
|
||||||
run_middleware = True
|
run_middleware = True
|
||||||
try:
|
try:
|
||||||
|
|
||||||
await self.dispatch(
|
await self.dispatch(
|
||||||
"http.routing.before",
|
"http.routing.before",
|
||||||
inline=True,
|
inline=True,
|
||||||
|
@ -926,7 +923,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||||
and request.stream.request_body
|
and request.stream.request_body
|
||||||
and not route.extra.ignore_body
|
and not route.extra.ignore_body
|
||||||
):
|
):
|
||||||
|
|
||||||
if hasattr(handler, "is_stream"):
|
if hasattr(handler, "is_stream"):
|
||||||
# Streaming handler: lift the size limit
|
# Streaming handler: lift the size limit
|
||||||
request.stream.request_max_size = float("inf")
|
request.stream.request_max_size = float("inf")
|
||||||
|
@ -1000,7 +996,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||||
...
|
...
|
||||||
await response.send(end_stream=True)
|
await response.send(end_stream=True)
|
||||||
elif isinstance(response, ResponseStream):
|
elif isinstance(response, ResponseStream):
|
||||||
resp = await response(request) # type: ignore
|
resp = await response(request)
|
||||||
await self.dispatch(
|
await self.dispatch(
|
||||||
"http.lifecycle.response",
|
"http.lifecycle.response",
|
||||||
inline=True,
|
inline=True,
|
||||||
|
@ -1009,7 +1005,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||||
"response": resp,
|
"response": resp,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await response.eof() # type: ignore
|
await response.eof()
|
||||||
else:
|
else:
|
||||||
if not hasattr(handler, "is_websocket"):
|
if not hasattr(handler, "is_websocket"):
|
||||||
raise ServerError(
|
raise ServerError(
|
||||||
|
|
|
@ -40,6 +40,8 @@ FULL_COLOR_LOGO = """
|
||||||
|
|
||||||
""" # noqa
|
""" # noqa
|
||||||
|
|
||||||
|
SVG_LOGO = """<svg id=logo alt=Sanic viewBox="0 0 964 279"><path d="M107 222c9-2 10-20 1-22s-20-2-30-2-17 7-16 14 6 10 15 10h30zm115-1c16-2 30-11 35-23s6-24 2-33-6-14-15-20-24-11-38-10c-7 3-10 13-5 19s17-1 24 4 15 14 13 24-5 15-14 18-50 0-74 0h-17c-6 4-10 15-4 20s16 2 23 3zM251 83q9-1 9-7 0-15-10-16h-13c-10 6-10 20 0 22zM147 60c-4 0-10 3-11 11s5 13 10 12 42 0 67 0c5-3 7-10 6-15s-4-8-9-8zm-33 1c-8 0-16 0-24 3s-20 10-25 20-6 24-4 36 15 22 26 27 78 8 94 3c4-4 4-12 0-18s-69 8-93-10c-8-7-9-23 0-30s12-10 20-10 12 2 16-3 1-15-5-18z" fill="#ff0d68"/><path d="M676 74c0-14-18-9-20 0s0 30 0 39 20 9 20 2zm-297-10c-12 2-15 12-23 23l-41 58H340l22-30c8-12 23-13 30-4s20 24 24 38-10 10-17 10l-68 2q-17 1-48 30c-7 6-10 20 0 24s15-8 20-13 20 -20 58-21h50 c20 2 33 9 52 30 8 10 24-4 16-13L384 65q-3-2-5-1zm131 0c-10 1-12 12-11 20v96c1 10-3 23 5 32s20-5 17-15c0-23-3-46 2-67 5-12 22-14 32-5l103 87c7 5 19 1 18-9v-64c-3-10-20-9-21 2s-20 22-30 13l-97-80c-5-4-10-10-18-10zM701 76v128c2 10 15 12 20 4s0-102 0-124s-20-18-20-7z M850 63c-35 0-69-2-86 15s-20 60-13 66 13 8 16 0 1-10 1-27 12-26 20-32 66-5 85-5 31 4 31-10-18-7-54-7M764 159c-6-2-15-2-16 12s19 37 33 43 23 8 25-4-4-11-11-14q-9-3-22-18c-4-7-3-16-10-19zM828 196c-4 0-8 1-10 5s-4 12 0 15 8 2 12 2h60c5 0 10-2 12-6 3-7-1-16-8-16z" fill="#e1e1e1"/></svg>""" # noqa
|
||||||
|
|
||||||
ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ from sanic.mixins.listeners import ListenerMixin
|
||||||
from sanic.mixins.middleware import MiddlewareMixin
|
from sanic.mixins.middleware import MiddlewareMixin
|
||||||
from sanic.mixins.routes import RouteMixin
|
from sanic.mixins.routes import RouteMixin
|
||||||
from sanic.mixins.signals import SignalMixin
|
from sanic.mixins.signals import SignalMixin
|
||||||
|
from sanic.mixins.static import StaticMixin
|
||||||
|
|
||||||
|
|
||||||
VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
|
VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
|
||||||
|
@ -16,6 +17,7 @@ VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
|
||||||
|
|
||||||
class BaseSanic(
|
class BaseSanic(
|
||||||
RouteMixin,
|
RouteMixin,
|
||||||
|
StaticMixin,
|
||||||
MiddlewareMixin,
|
MiddlewareMixin,
|
||||||
ListenerMixin,
|
ListenerMixin,
|
||||||
ExceptionMixin,
|
ExceptionMixin,
|
||||||
|
|
|
@ -304,9 +304,6 @@ class Blueprint(BaseSanic):
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
for future in self._future_routes:
|
for future in self._future_routes:
|
||||||
# attach the blueprint name to the handler so that it can be
|
|
||||||
# prefixed properly in the router
|
|
||||||
future.handler.__blueprintname__ = self.name
|
|
||||||
# Prepend the blueprint URI prefix if available
|
# Prepend the blueprint URI prefix if available
|
||||||
uri = self._setup_uri(future.uri, url_prefix)
|
uri = self._setup_uri(future.uri, url_prefix)
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ from sanic.compat import UpperStrEnum
|
||||||
|
|
||||||
|
|
||||||
class HTTPMethod(UpperStrEnum):
|
class HTTPMethod(UpperStrEnum):
|
||||||
|
|
||||||
GET = auto()
|
GET = auto()
|
||||||
POST = auto()
|
POST = auto()
|
||||||
PUT = auto()
|
PUT = auto()
|
||||||
|
@ -15,7 +14,6 @@ class HTTPMethod(UpperStrEnum):
|
||||||
|
|
||||||
|
|
||||||
class LocalCertCreator(UpperStrEnum):
|
class LocalCertCreator(UpperStrEnum):
|
||||||
|
|
||||||
AUTO = auto()
|
AUTO = auto()
|
||||||
TRUSTME = auto()
|
TRUSTME = auto()
|
||||||
MKCERT = auto()
|
MKCERT = auto()
|
||||||
|
|
|
@ -12,6 +12,7 @@ Setting ``app.config.FALLBACK_ERROR_FORMAT = "auto"`` will enable a switch that
|
||||||
will attempt to provide an appropriate response format based upon the
|
will attempt to provide an appropriate response format based upon the
|
||||||
request type.
|
request type.
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import typing as t
|
import typing as t
|
||||||
|
@ -21,8 +22,7 @@ from traceback import extract_tb
|
||||||
|
|
||||||
from sanic.exceptions import BadRequest, SanicException
|
from sanic.exceptions import BadRequest, SanicException
|
||||||
from sanic.helpers import STATUS_CODES
|
from sanic.helpers import STATUS_CODES
|
||||||
from sanic.request import Request
|
from sanic.response import html, json, text
|
||||||
from sanic.response import HTTPResponse, html, json, text
|
|
||||||
|
|
||||||
|
|
||||||
dumps: t.Callable[..., str]
|
dumps: t.Callable[..., str]
|
||||||
|
@ -33,6 +33,8 @@ try:
|
||||||
except ImportError: # noqa
|
except ImportError: # noqa
|
||||||
from json import dumps
|
from json import dumps
|
||||||
|
|
||||||
|
if t.TYPE_CHECKING:
|
||||||
|
from sanic import HTTPResponse, Request
|
||||||
|
|
||||||
DEFAULT_FORMAT = "auto"
|
DEFAULT_FORMAT = "auto"
|
||||||
FALLBACK_TEXT = (
|
FALLBACK_TEXT = (
|
||||||
|
@ -404,16 +406,13 @@ CONTENT_TYPE_BY_RENDERERS = {
|
||||||
v: k for k, v in RENDERERS_BY_CONTENT_TYPE.items()
|
v: k for k, v in RENDERERS_BY_CONTENT_TYPE.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Handler source code is checked for which response types it returns with the
|
||||||
|
# route error_format="auto" (default) to determine which format to use.
|
||||||
RESPONSE_MAPPING = {
|
RESPONSE_MAPPING = {
|
||||||
"empty": "html",
|
|
||||||
"json": "json",
|
"json": "json",
|
||||||
"text": "text",
|
"text": "text",
|
||||||
"raw": "text",
|
|
||||||
"html": "html",
|
"html": "html",
|
||||||
"file": "html",
|
"JSONResponse": "json",
|
||||||
"file_stream": "text",
|
|
||||||
"stream": "text",
|
|
||||||
"redirect": "html",
|
|
||||||
"text/plain": "text",
|
"text/plain": "text",
|
||||||
"text/html": "html",
|
"text/html": "html",
|
||||||
"application/json": "json",
|
"application/json": "json",
|
||||||
|
|
10
sanic/handlers/__init__.py
Normal file
10
sanic/handlers/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from .content_range import ContentRangeHandler
|
||||||
|
from .directory import DirectoryHandler
|
||||||
|
from .error import ErrorHandler
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"ContentRangeHandler",
|
||||||
|
"DirectoryHandler",
|
||||||
|
"ErrorHandler",
|
||||||
|
)
|
78
sanic/handlers/content_range.py
Normal file
78
sanic/handlers/content_range.py
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sanic.exceptions import (
|
||||||
|
HeaderNotFound,
|
||||||
|
InvalidRangeType,
|
||||||
|
RangeNotSatisfiable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentRangeHandler:
|
||||||
|
"""
|
||||||
|
A mechanism to parse and process the incoming request headers to
|
||||||
|
extract the content range information.
|
||||||
|
|
||||||
|
:param request: Incoming api request
|
||||||
|
:param stats: Stats related to the content
|
||||||
|
|
||||||
|
:type request: :class:`sanic.request.Request`
|
||||||
|
:type stats: :class:`posix.stat_result`
|
||||||
|
|
||||||
|
:ivar start: Content Range start
|
||||||
|
:ivar end: Content Range end
|
||||||
|
:ivar size: Length of the content
|
||||||
|
:ivar total: Total size identified by the :class:`posix.stat_result`
|
||||||
|
instance
|
||||||
|
:ivar ContentRangeHandler.headers: Content range header ``dict``
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("start", "end", "size", "total", "headers")
|
||||||
|
|
||||||
|
def __init__(self, request, stats):
|
||||||
|
self.total = stats.st_size
|
||||||
|
_range = request.headers.getone("range", None)
|
||||||
|
if _range is None:
|
||||||
|
raise HeaderNotFound("Range Header Not Found")
|
||||||
|
unit, _, value = tuple(map(str.strip, _range.partition("=")))
|
||||||
|
if unit != "bytes":
|
||||||
|
raise InvalidRangeType(
|
||||||
|
"%s is not a valid Range Type" % (unit,), self
|
||||||
|
)
|
||||||
|
start_b, _, end_b = tuple(map(str.strip, value.partition("-")))
|
||||||
|
try:
|
||||||
|
self.start = int(start_b) if start_b else None
|
||||||
|
except ValueError:
|
||||||
|
raise RangeNotSatisfiable(
|
||||||
|
"'%s' is invalid for Content Range" % (start_b,), self
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.end = int(end_b) if end_b else None
|
||||||
|
except ValueError:
|
||||||
|
raise RangeNotSatisfiable(
|
||||||
|
"'%s' is invalid for Content Range" % (end_b,), self
|
||||||
|
)
|
||||||
|
if self.end is None:
|
||||||
|
if self.start is None:
|
||||||
|
raise RangeNotSatisfiable(
|
||||||
|
"Invalid for Content Range parameters", self
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# this case represents `Content-Range: bytes 5-`
|
||||||
|
self.end = self.total - 1
|
||||||
|
else:
|
||||||
|
if self.start is None:
|
||||||
|
# this case represents `Content-Range: bytes -5`
|
||||||
|
self.start = self.total - self.end
|
||||||
|
self.end = self.total - 1
|
||||||
|
if self.start >= self.end:
|
||||||
|
raise RangeNotSatisfiable(
|
||||||
|
"Invalid for Content Range parameters", self
|
||||||
|
)
|
||||||
|
self.size = self.end - self.start + 1
|
||||||
|
self.headers = {
|
||||||
|
"Content-Range": "bytes %s-%s/%s"
|
||||||
|
% (self.start, self.end, self.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
return self.size > 0
|
84
sanic/handlers/directory.py
Normal file
84
sanic/handlers/directory.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from operator import itemgetter
|
||||||
|
from pathlib import Path
|
||||||
|
from stat import S_ISDIR
|
||||||
|
from typing import Dict, Iterable, Optional, Sequence, Union, cast
|
||||||
|
|
||||||
|
from sanic.exceptions import NotFound
|
||||||
|
from sanic.pages.directory_page import DirectoryPage, FileInfo
|
||||||
|
from sanic.request import Request
|
||||||
|
from sanic.response import file, html, redirect
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryHandler:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
directory: Path,
|
||||||
|
directory_view: bool = False,
|
||||||
|
index: Optional[Union[str, Sequence[str]]] = None,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(index, str):
|
||||||
|
index = [index]
|
||||||
|
elif index is None:
|
||||||
|
index = []
|
||||||
|
self.base = uri.strip("/")
|
||||||
|
self.directory = directory
|
||||||
|
self.directory_view = directory_view
|
||||||
|
self.index = tuple(index)
|
||||||
|
|
||||||
|
async def handle(self, request: Request, path: str):
|
||||||
|
current = path.strip("/")[len(self.base) :].strip("/") # noqa: E203
|
||||||
|
for file_name in self.index:
|
||||||
|
index_file = self.directory / current / file_name
|
||||||
|
if index_file.is_file():
|
||||||
|
return await file(index_file)
|
||||||
|
|
||||||
|
if self.directory_view:
|
||||||
|
return self._index(
|
||||||
|
self.directory / current, path, request.app.debug
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.index:
|
||||||
|
raise NotFound("File not found")
|
||||||
|
|
||||||
|
raise IsADirectoryError(f"{self.directory.as_posix()} is a directory")
|
||||||
|
|
||||||
|
def _index(self, location: Path, path: str, debug: bool):
|
||||||
|
# Remove empty path elements, append slash
|
||||||
|
if "//" in path or not path.endswith("/"):
|
||||||
|
return redirect(
|
||||||
|
"/" + "".join([f"{p}/" for p in path.split("/") if p])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render file browser
|
||||||
|
page = DirectoryPage(self._iter_files(location), path, debug)
|
||||||
|
return html(page.render())
|
||||||
|
|
||||||
|
def _prepare_file(self, path: Path) -> Dict[str, Union[int, str]]:
|
||||||
|
stat = path.stat()
|
||||||
|
modified = (
|
||||||
|
datetime.fromtimestamp(stat.st_mtime)
|
||||||
|
.isoformat()[:19]
|
||||||
|
.replace("T", " ")
|
||||||
|
)
|
||||||
|
is_dir = S_ISDIR(stat.st_mode)
|
||||||
|
icon = "📁" if is_dir else "📄"
|
||||||
|
file_name = path.name
|
||||||
|
if is_dir:
|
||||||
|
file_name += "/"
|
||||||
|
return {
|
||||||
|
"priority": is_dir * -1,
|
||||||
|
"file_name": file_name,
|
||||||
|
"icon": icon,
|
||||||
|
"file_access": modified,
|
||||||
|
"file_size": stat.st_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _iter_files(self, location: Path) -> Iterable[FileInfo]:
|
||||||
|
prepared = [self._prepare_file(f) for f in location.iterdir()]
|
||||||
|
for item in sorted(prepared, key=itemgetter("priority", "file_name")):
|
||||||
|
del item["priority"]
|
||||||
|
yield cast(FileInfo, item)
|
|
@ -3,11 +3,6 @@ from __future__ import annotations
|
||||||
from typing import Dict, List, Optional, Tuple, Type
|
from typing import Dict, List, Optional, Tuple, Type
|
||||||
|
|
||||||
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
|
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
|
||||||
from sanic.exceptions import (
|
|
||||||
HeaderNotFound,
|
|
||||||
InvalidRangeType,
|
|
||||||
RangeNotSatisfiable,
|
|
||||||
)
|
|
||||||
from sanic.log import deprecation, error_logger
|
from sanic.log import deprecation, error_logger
|
||||||
from sanic.models.handler_types import RouteHandler
|
from sanic.models.handler_types import RouteHandler
|
||||||
from sanic.response import text
|
from sanic.response import text
|
||||||
|
@ -23,7 +18,6 @@ class ErrorHandler:
|
||||||
by the developers to perform a wide range of tasks from recording the error
|
by the developers to perform a wide range of tasks from recording the error
|
||||||
stats to reporting them to an external service that can be used for
|
stats to reporting them to an external service that can be used for
|
||||||
realtime alerting system.
|
realtime alerting system.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
@ -196,74 +190,3 @@ class ErrorHandler:
|
||||||
error_logger.exception(
|
error_logger.exception(
|
||||||
"Exception occurred while handling uri: %s", url
|
"Exception occurred while handling uri: %s", url
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContentRangeHandler:
|
|
||||||
"""
|
|
||||||
A mechanism to parse and process the incoming request headers to
|
|
||||||
extract the content range information.
|
|
||||||
|
|
||||||
:param request: Incoming api request
|
|
||||||
:param stats: Stats related to the content
|
|
||||||
|
|
||||||
:type request: :class:`sanic.request.Request`
|
|
||||||
:type stats: :class:`posix.stat_result`
|
|
||||||
|
|
||||||
:ivar start: Content Range start
|
|
||||||
:ivar end: Content Range end
|
|
||||||
:ivar size: Length of the content
|
|
||||||
:ivar total: Total size identified by the :class:`posix.stat_result`
|
|
||||||
instance
|
|
||||||
:ivar ContentRangeHandler.headers: Content range header ``dict``
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("start", "end", "size", "total", "headers")
|
|
||||||
|
|
||||||
def __init__(self, request, stats):
|
|
||||||
self.total = stats.st_size
|
|
||||||
_range = request.headers.getone("range", None)
|
|
||||||
if _range is None:
|
|
||||||
raise HeaderNotFound("Range Header Not Found")
|
|
||||||
unit, _, value = tuple(map(str.strip, _range.partition("=")))
|
|
||||||
if unit != "bytes":
|
|
||||||
raise InvalidRangeType(
|
|
||||||
"%s is not a valid Range Type" % (unit,), self
|
|
||||||
)
|
|
||||||
start_b, _, end_b = tuple(map(str.strip, value.partition("-")))
|
|
||||||
try:
|
|
||||||
self.start = int(start_b) if start_b else None
|
|
||||||
except ValueError:
|
|
||||||
raise RangeNotSatisfiable(
|
|
||||||
"'%s' is invalid for Content Range" % (start_b,), self
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
self.end = int(end_b) if end_b else None
|
|
||||||
except ValueError:
|
|
||||||
raise RangeNotSatisfiable(
|
|
||||||
"'%s' is invalid for Content Range" % (end_b,), self
|
|
||||||
)
|
|
||||||
if self.end is None:
|
|
||||||
if self.start is None:
|
|
||||||
raise RangeNotSatisfiable(
|
|
||||||
"Invalid for Content Range parameters", self
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# this case represents `Content-Range: bytes 5-`
|
|
||||||
self.end = self.total - 1
|
|
||||||
else:
|
|
||||||
if self.start is None:
|
|
||||||
# this case represents `Content-Range: bytes -5`
|
|
||||||
self.start = self.total - self.end
|
|
||||||
self.end = self.total - 1
|
|
||||||
if self.start >= self.end:
|
|
||||||
raise RangeNotSatisfiable(
|
|
||||||
"Invalid for Content Range parameters", self
|
|
||||||
)
|
|
||||||
self.size = self.end - self.start + 1
|
|
||||||
self.headers = {
|
|
||||||
"Content-Range": "bytes %s-%s/%s"
|
|
||||||
% (self.start, self.end, self.total)
|
|
||||||
}
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return self.size > 0
|
|
|
@ -126,7 +126,6 @@ class CertCreator(ABC):
|
||||||
local_tls_key,
|
local_tls_key,
|
||||||
local_tls_cert,
|
local_tls_cert,
|
||||||
) -> CertCreator:
|
) -> CertCreator:
|
||||||
|
|
||||||
creator: Optional[CertCreator] = None
|
creator: Optional[CertCreator] = None
|
||||||
|
|
||||||
cert_creator_options: Tuple[
|
cert_creator_options: Tuple[
|
||||||
|
|
35
sanic/mixins/base.py
Normal file
35
sanic/mixins/base.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sanic.base.meta import SanicMeta
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMixin(metaclass=SanicMeta):
|
||||||
|
name: str
|
||||||
|
strict_slashes: Optional[bool]
|
||||||
|
|
||||||
|
def _generate_name(self, *objects) -> str:
|
||||||
|
name = None
|
||||||
|
|
||||||
|
for obj in objects:
|
||||||
|
if obj:
|
||||||
|
if isinstance(obj, str):
|
||||||
|
name = obj
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
name = obj.name
|
||||||
|
except AttributeError:
|
||||||
|
try:
|
||||||
|
name = obj.__name__
|
||||||
|
except AttributeError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not name: # noqa
|
||||||
|
raise ValueError("Could not generate a name for handler")
|
||||||
|
|
||||||
|
if not name.startswith(f"{self.name}."):
|
||||||
|
name = f"{self.name}.{name}"
|
||||||
|
|
||||||
|
return name
|
|
@ -1,11 +1,6 @@
|
||||||
from ast import NodeVisitor, Return, parse
|
from ast import NodeVisitor, Return, parse
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from email.utils import formatdate
|
|
||||||
from functools import partial, wraps
|
|
||||||
from inspect import getsource, signature
|
from inspect import getsource, signature
|
||||||
from mimetypes import guess_type
|
|
||||||
from os import path
|
|
||||||
from pathlib import Path, PurePath
|
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
|
@ -19,20 +14,15 @@ from typing import (
|
||||||
Union,
|
Union,
|
||||||
cast,
|
cast,
|
||||||
)
|
)
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from sanic_routing.route import Route
|
from sanic_routing.route import Route
|
||||||
|
|
||||||
from sanic.base.meta import SanicMeta
|
from sanic.base.meta import SanicMeta
|
||||||
from sanic.compat import stat_async
|
from sanic.constants import HTTP_METHODS
|
||||||
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS
|
|
||||||
from sanic.errorpages import RESPONSE_MAPPING
|
from sanic.errorpages import RESPONSE_MAPPING
|
||||||
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
|
from sanic.mixins.base import BaseMixin
|
||||||
from sanic.handlers import ContentRangeHandler
|
|
||||||
from sanic.log import error_logger
|
|
||||||
from sanic.models.futures import FutureRoute, FutureStatic
|
from sanic.models.futures import FutureRoute, FutureStatic
|
||||||
from sanic.models.handler_types import RouteHandler
|
from sanic.models.handler_types import RouteHandler
|
||||||
from sanic.response import HTTPResponse, file, file_stream, validate_file
|
|
||||||
from sanic.types import HashableDict
|
from sanic.types import HashableDict
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,20 +31,14 @@ RouteWrapper = Callable[
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class RouteMixin(metaclass=SanicMeta):
|
class RouteMixin(BaseMixin, metaclass=SanicMeta):
|
||||||
name: str
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
self._future_routes: Set[FutureRoute] = set()
|
self._future_routes: Set[FutureRoute] = set()
|
||||||
self._future_statics: Set[FutureStatic] = set()
|
self._future_statics: Set[FutureStatic] = set()
|
||||||
self.strict_slashes: Optional[bool] = False
|
|
||||||
|
|
||||||
def _apply_route(self, route: FutureRoute) -> List[Route]:
|
def _apply_route(self, route: FutureRoute) -> List[Route]:
|
||||||
raise NotImplementedError # noqa
|
raise NotImplementedError # noqa
|
||||||
|
|
||||||
def _apply_static(self, static: FutureStatic) -> Route:
|
|
||||||
raise NotImplementedError # noqa
|
|
||||||
|
|
||||||
def route(
|
def route(
|
||||||
self,
|
self,
|
||||||
uri: str,
|
uri: str,
|
||||||
|
@ -688,324 +672,6 @@ class RouteMixin(metaclass=SanicMeta):
|
||||||
**ctx_kwargs,
|
**ctx_kwargs,
|
||||||
)(handler)
|
)(handler)
|
||||||
|
|
||||||
def static(
|
|
||||||
self,
|
|
||||||
uri: str,
|
|
||||||
file_or_directory: Union[str, bytes, PurePath],
|
|
||||||
pattern: str = r"/?.+",
|
|
||||||
use_modified_since: bool = True,
|
|
||||||
use_content_range: bool = False,
|
|
||||||
stream_large_files: bool = False,
|
|
||||||
name: str = "static",
|
|
||||||
host: Optional[str] = None,
|
|
||||||
strict_slashes: Optional[bool] = None,
|
|
||||||
content_type: Optional[bool] = None,
|
|
||||||
apply: bool = True,
|
|
||||||
resource_type: Optional[str] = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Register a root to serve files from. The input can either be a
|
|
||||||
file or a directory. This method will enable an easy and simple way
|
|
||||||
to setup the :class:`Route` necessary to serve the static files.
|
|
||||||
|
|
||||||
:param uri: URL path to be used for serving static content
|
|
||||||
:param file_or_directory: Path for the Static file/directory with
|
|
||||||
static files
|
|
||||||
:param pattern: Regex Pattern identifying the valid static files
|
|
||||||
:param use_modified_since: If true, send file modified time, and return
|
|
||||||
not modified if the browser's matches the server's
|
|
||||||
:param use_content_range: If true, process header for range requests
|
|
||||||
and sends the file part that is requested
|
|
||||||
:param stream_large_files: If true, use the
|
|
||||||
:func:`StreamingHTTPResponse.file_stream` handler rather
|
|
||||||
than the :func:`HTTPResponse.file` handler to send the file.
|
|
||||||
If this is an integer, this represents the threshold size to
|
|
||||||
switch to :func:`StreamingHTTPResponse.file_stream`
|
|
||||||
:param name: user defined name used for url_for
|
|
||||||
:param host: Host IP or FQDN for the service to use
|
|
||||||
:param strict_slashes: Instruct :class:`Sanic` to check if the request
|
|
||||||
URLs need to terminate with a */*
|
|
||||||
:param content_type: user defined content type for header
|
|
||||||
:return: routes registered on the router
|
|
||||||
:rtype: List[sanic.router.Route]
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = self._generate_name(name)
|
|
||||||
|
|
||||||
if strict_slashes is None and self.strict_slashes is not None:
|
|
||||||
strict_slashes = self.strict_slashes
|
|
||||||
|
|
||||||
if not isinstance(file_or_directory, (str, bytes, PurePath)):
|
|
||||||
raise ValueError(
|
|
||||||
f"Static route must be a valid path, not {file_or_directory}"
|
|
||||||
)
|
|
||||||
|
|
||||||
static = FutureStatic(
|
|
||||||
uri,
|
|
||||||
file_or_directory,
|
|
||||||
pattern,
|
|
||||||
use_modified_since,
|
|
||||||
use_content_range,
|
|
||||||
stream_large_files,
|
|
||||||
name,
|
|
||||||
host,
|
|
||||||
strict_slashes,
|
|
||||||
content_type,
|
|
||||||
resource_type,
|
|
||||||
)
|
|
||||||
self._future_statics.add(static)
|
|
||||||
|
|
||||||
if apply:
|
|
||||||
self._apply_static(static)
|
|
||||||
|
|
||||||
def _generate_name(self, *objects) -> str:
|
|
||||||
name = None
|
|
||||||
|
|
||||||
for obj in objects:
|
|
||||||
if obj:
|
|
||||||
if isinstance(obj, str):
|
|
||||||
name = obj
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
name = obj.name
|
|
||||||
except AttributeError:
|
|
||||||
try:
|
|
||||||
name = obj.__name__
|
|
||||||
except AttributeError:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not name: # noqa
|
|
||||||
raise ValueError("Could not generate a name for handler")
|
|
||||||
|
|
||||||
if not name.startswith(f"{self.name}."):
|
|
||||||
name = f"{self.name}.{name}"
|
|
||||||
|
|
||||||
return name
|
|
||||||
|
|
||||||
async def _get_file_path(self, file_or_directory, __file_uri__, not_found):
|
|
||||||
file_path_raw = Path(unquote(file_or_directory))
|
|
||||||
root_path = file_path = file_path_raw.resolve()
|
|
||||||
|
|
||||||
if __file_uri__:
|
|
||||||
# Strip all / that in the beginning of the URL to help prevent
|
|
||||||
# python from herping a derp and treating the uri as an
|
|
||||||
# absolute path
|
|
||||||
unquoted_file_uri = unquote(__file_uri__).lstrip("/")
|
|
||||||
file_path_raw = Path(file_or_directory, unquoted_file_uri)
|
|
||||||
file_path = file_path_raw.resolve()
|
|
||||||
if (
|
|
||||||
file_path < root_path and not file_path_raw.is_symlink()
|
|
||||||
) or ".." in file_path_raw.parts:
|
|
||||||
error_logger.exception(
|
|
||||||
f"File not found: path={file_or_directory}, "
|
|
||||||
f"relative_url={__file_uri__}"
|
|
||||||
)
|
|
||||||
raise not_found
|
|
||||||
|
|
||||||
try:
|
|
||||||
file_path.relative_to(root_path)
|
|
||||||
except ValueError:
|
|
||||||
if not file_path_raw.is_symlink():
|
|
||||||
error_logger.exception(
|
|
||||||
f"File not found: path={file_or_directory}, "
|
|
||||||
f"relative_url={__file_uri__}"
|
|
||||||
)
|
|
||||||
raise not_found
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
async def _static_request_handler(
|
|
||||||
self,
|
|
||||||
file_or_directory,
|
|
||||||
use_modified_since,
|
|
||||||
use_content_range,
|
|
||||||
stream_large_files,
|
|
||||||
request,
|
|
||||||
content_type=None,
|
|
||||||
__file_uri__=None,
|
|
||||||
):
|
|
||||||
not_found = FileNotFound(
|
|
||||||
"File not found",
|
|
||||||
path=file_or_directory,
|
|
||||||
relative_url=__file_uri__,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Merge served directory and requested file if provided
|
|
||||||
file_path = await self._get_file_path(
|
|
||||||
file_or_directory, __file_uri__, not_found
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
headers = {}
|
|
||||||
# Check if the client has been sent this file before
|
|
||||||
# and it has not been modified since
|
|
||||||
stats = None
|
|
||||||
if use_modified_since:
|
|
||||||
stats = await stat_async(file_path)
|
|
||||||
modified_since = stats.st_mtime
|
|
||||||
response = await validate_file(request.headers, modified_since)
|
|
||||||
if response:
|
|
||||||
return response
|
|
||||||
headers["Last-Modified"] = formatdate(
|
|
||||||
modified_since, usegmt=True
|
|
||||||
)
|
|
||||||
_range = None
|
|
||||||
if use_content_range:
|
|
||||||
_range = None
|
|
||||||
if not stats:
|
|
||||||
stats = await stat_async(file_path)
|
|
||||||
headers["Accept-Ranges"] = "bytes"
|
|
||||||
headers["Content-Length"] = str(stats.st_size)
|
|
||||||
if request.method != "HEAD":
|
|
||||||
try:
|
|
||||||
_range = ContentRangeHandler(request, stats)
|
|
||||||
except HeaderNotFound:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
del headers["Content-Length"]
|
|
||||||
headers.update(_range.headers)
|
|
||||||
|
|
||||||
if "content-type" not in headers:
|
|
||||||
content_type = (
|
|
||||||
content_type
|
|
||||||
or guess_type(file_path)[0]
|
|
||||||
or DEFAULT_HTTP_CONTENT_TYPE
|
|
||||||
)
|
|
||||||
|
|
||||||
if "charset=" not in content_type and (
|
|
||||||
content_type.startswith("text/")
|
|
||||||
or content_type == "application/javascript"
|
|
||||||
):
|
|
||||||
content_type += "; charset=utf-8"
|
|
||||||
|
|
||||||
headers["Content-Type"] = content_type
|
|
||||||
|
|
||||||
if request.method == "HEAD":
|
|
||||||
return HTTPResponse(headers=headers)
|
|
||||||
else:
|
|
||||||
if stream_large_files:
|
|
||||||
if type(stream_large_files) == int:
|
|
||||||
threshold = stream_large_files
|
|
||||||
else:
|
|
||||||
threshold = 1024 * 1024
|
|
||||||
|
|
||||||
if not stats:
|
|
||||||
stats = await stat_async(file_path)
|
|
||||||
if stats.st_size >= threshold:
|
|
||||||
return await file_stream(
|
|
||||||
file_path, headers=headers, _range=_range
|
|
||||||
)
|
|
||||||
return await file(file_path, headers=headers, _range=_range)
|
|
||||||
except RangeNotSatisfiable:
|
|
||||||
raise
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise not_found
|
|
||||||
except Exception:
|
|
||||||
error_logger.exception(
|
|
||||||
f"Exception in static request handler: "
|
|
||||||
f"path={file_or_directory}, "
|
|
||||||
f"relative_url={__file_uri__}"
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _register_static(
|
|
||||||
self,
|
|
||||||
static: FutureStatic,
|
|
||||||
):
|
|
||||||
# TODO: Though sanic is not a file server, I feel like we should
|
|
||||||
# at least make a good effort here. Modified-since is nice, but
|
|
||||||
# we could also look into etags, expires, and caching
|
|
||||||
"""
|
|
||||||
Register a static directory handler with Sanic by adding a route to the
|
|
||||||
router and registering a handler.
|
|
||||||
|
|
||||||
:param app: Sanic
|
|
||||||
:param file_or_directory: File or directory path to serve from
|
|
||||||
:type file_or_directory: Union[str,bytes,Path]
|
|
||||||
:param uri: URL to serve from
|
|
||||||
:type uri: str
|
|
||||||
:param pattern: regular expression used to match files in the URL
|
|
||||||
:param use_modified_since: If true, send file modified time, and return
|
|
||||||
not modified if the browser's matches the
|
|
||||||
server's
|
|
||||||
:param use_content_range: If true, process header for range requests
|
|
||||||
and sends the file part that is requested
|
|
||||||
:param stream_large_files: If true, use the file_stream() handler
|
|
||||||
rather than the file() handler to send the file
|
|
||||||
If this is an integer, this represents the
|
|
||||||
threshold size to switch to file_stream()
|
|
||||||
:param name: user defined name used for url_for
|
|
||||||
:type name: str
|
|
||||||
:param content_type: user defined content type for header
|
|
||||||
:return: registered static routes
|
|
||||||
:rtype: List[sanic.router.Route]
|
|
||||||
"""
|
|
||||||
|
|
||||||
if isinstance(static.file_or_directory, bytes):
|
|
||||||
file_or_directory = static.file_or_directory.decode("utf-8")
|
|
||||||
elif isinstance(static.file_or_directory, PurePath):
|
|
||||||
file_or_directory = str(static.file_or_directory)
|
|
||||||
elif not isinstance(static.file_or_directory, str):
|
|
||||||
raise ValueError("Invalid file path string.")
|
|
||||||
else:
|
|
||||||
file_or_directory = static.file_or_directory
|
|
||||||
|
|
||||||
uri = static.uri
|
|
||||||
name = static.name
|
|
||||||
# If we're not trying to match a file directly,
|
|
||||||
# serve from the folder
|
|
||||||
if not static.resource_type:
|
|
||||||
if not path.isfile(file_or_directory):
|
|
||||||
uri = uri.rstrip("/")
|
|
||||||
uri += "/<__file_uri__:path>"
|
|
||||||
elif static.resource_type == "dir":
|
|
||||||
if path.isfile(file_or_directory):
|
|
||||||
raise TypeError(
|
|
||||||
"Resource type improperly identified as directory. "
|
|
||||||
f"'{file_or_directory}'"
|
|
||||||
)
|
|
||||||
uri = uri.rstrip("/")
|
|
||||||
uri += "/<__file_uri__:path>"
|
|
||||||
elif static.resource_type == "file" and not path.isfile(
|
|
||||||
file_or_directory
|
|
||||||
):
|
|
||||||
raise TypeError(
|
|
||||||
"Resource type improperly identified as file. "
|
|
||||||
f"'{file_or_directory}'"
|
|
||||||
)
|
|
||||||
elif static.resource_type != "file":
|
|
||||||
raise ValueError(
|
|
||||||
"The resource_type should be set to 'file' or 'dir'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# special prefix for static files
|
|
||||||
# if not static.name.startswith("_static_"):
|
|
||||||
# name = f"_static_{static.name}"
|
|
||||||
|
|
||||||
_handler = wraps(self._static_request_handler)(
|
|
||||||
partial(
|
|
||||||
self._static_request_handler,
|
|
||||||
file_or_directory,
|
|
||||||
static.use_modified_since,
|
|
||||||
static.use_content_range,
|
|
||||||
static.stream_large_files,
|
|
||||||
content_type=static.content_type,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
route, _ = self.route( # type: ignore
|
|
||||||
uri=uri,
|
|
||||||
methods=["GET", "HEAD"],
|
|
||||||
name=name,
|
|
||||||
host=static.host,
|
|
||||||
strict_slashes=static.strict_slashes,
|
|
||||||
static=True,
|
|
||||||
)(_handler)
|
|
||||||
|
|
||||||
return route
|
|
||||||
|
|
||||||
def _determine_error_format(self, handler) -> str:
|
def _determine_error_format(self, handler) -> str:
|
||||||
with suppress(OSError, TypeError):
|
with suppress(OSError, TypeError):
|
||||||
src = dedent(getsource(handler))
|
src = dedent(getsource(handler))
|
||||||
|
|
|
@ -1109,7 +1109,6 @@ class StartupMixin(metaclass=SanicMeta):
|
||||||
app: StartupMixin,
|
app: StartupMixin,
|
||||||
server_info: ApplicationServerInfo,
|
server_info: ApplicationServerInfo,
|
||||||
) -> None: # no cov
|
) -> None: # no cov
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# We should never get to this point without a server
|
# We should never get to this point without a server
|
||||||
# This is primarily to keep mypy happy
|
# This is primarily to keep mypy happy
|
||||||
|
|
348
sanic/mixins/static.py
Normal file
348
sanic/mixins/static.py
Normal file
|
@ -0,0 +1,348 @@
|
||||||
|
from email.utils import formatdate
|
||||||
|
from functools import partial, wraps
|
||||||
|
from mimetypes import guess_type
|
||||||
|
from os import PathLike, path
|
||||||
|
from pathlib import Path, PurePath
|
||||||
|
from typing import Optional, Sequence, Set, Union, cast
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from sanic_routing.route import Route
|
||||||
|
|
||||||
|
from sanic.base.meta import SanicMeta
|
||||||
|
from sanic.compat import stat_async
|
||||||
|
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
|
||||||
|
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
|
||||||
|
from sanic.handlers import ContentRangeHandler
|
||||||
|
from sanic.handlers.directory import DirectoryHandler
|
||||||
|
from sanic.log import deprecation, error_logger
|
||||||
|
from sanic.mixins.base import BaseMixin
|
||||||
|
from sanic.models.futures import FutureStatic
|
||||||
|
from sanic.request import Request
|
||||||
|
from sanic.response import HTTPResponse, file, file_stream, validate_file
|
||||||
|
|
||||||
|
|
||||||
|
class StaticMixin(BaseMixin, metaclass=SanicMeta):
|
||||||
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
self._future_statics: Set[FutureStatic] = set()
|
||||||
|
|
||||||
|
def _apply_static(self, static: FutureStatic) -> Route:
|
||||||
|
raise NotImplementedError # noqa
|
||||||
|
|
||||||
|
def static(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
file_or_directory: Union[PathLike, str, bytes],
|
||||||
|
pattern: str = r"/?.+",
|
||||||
|
use_modified_since: bool = True,
|
||||||
|
use_content_range: bool = False,
|
||||||
|
stream_large_files: Union[bool, int] = False,
|
||||||
|
name: str = "static",
|
||||||
|
host: Optional[str] = None,
|
||||||
|
strict_slashes: Optional[bool] = None,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
apply: bool = True,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
index: Optional[Union[str, Sequence[str]]] = None,
|
||||||
|
directory_view: bool = False,
|
||||||
|
directory_handler: Optional[DirectoryHandler] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Register a root to serve files from. The input can either be a
|
||||||
|
file or a directory. This method will enable an easy and simple way
|
||||||
|
to setup the :class:`Route` necessary to serve the static files.
|
||||||
|
|
||||||
|
:param uri: URL path to be used for serving static content
|
||||||
|
:param file_or_directory: Path for the Static file/directory with
|
||||||
|
static files
|
||||||
|
:param pattern: Regex Pattern identifying the valid static files
|
||||||
|
:param use_modified_since: If true, send file modified time, and return
|
||||||
|
not modified if the browser's matches the server's
|
||||||
|
:param use_content_range: If true, process header for range requests
|
||||||
|
and sends the file part that is requested
|
||||||
|
:param stream_large_files: If true, use the
|
||||||
|
:func:`StreamingHTTPResponse.file_stream` handler rather
|
||||||
|
than the :func:`HTTPResponse.file` handler to send the file.
|
||||||
|
If this is an integer, this represents the threshold size to
|
||||||
|
switch to :func:`StreamingHTTPResponse.file_stream`
|
||||||
|
:param name: user defined name used for url_for
|
||||||
|
:param host: Host IP or FQDN for the service to use
|
||||||
|
:param strict_slashes: Instruct :class:`Sanic` to check if the request
|
||||||
|
URLs need to terminate with a */*
|
||||||
|
:param content_type: user defined content type for header
|
||||||
|
:param apply: If true, will register the route immediately
|
||||||
|
:param resource_type: Explicitly declare a resource to be a "
|
||||||
|
file" or a "dir"
|
||||||
|
:param index: When exposing against a directory, index is the name that
|
||||||
|
will be served as the default file. When multiple files names are
|
||||||
|
passed, then they will be tried in order.
|
||||||
|
:param directory_view: Whether to fallback to showing the directory
|
||||||
|
viewer when exposing a directory
|
||||||
|
:param directory_handler: An instance of :class:`DirectoryHandler`
|
||||||
|
that can be used for explicitly controlling and subclassing the
|
||||||
|
behavior of the default directory handler
|
||||||
|
:return: routes registered on the router
|
||||||
|
:rtype: List[sanic.router.Route]
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = self._generate_name(name)
|
||||||
|
|
||||||
|
if strict_slashes is None and self.strict_slashes is not None:
|
||||||
|
strict_slashes = self.strict_slashes
|
||||||
|
|
||||||
|
if not isinstance(file_or_directory, (str, bytes, PurePath)):
|
||||||
|
raise ValueError(
|
||||||
|
f"Static route must be a valid path, not {file_or_directory}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(file_or_directory, bytes):
|
||||||
|
deprecation(
|
||||||
|
"Serving a static directory with a bytes string is "
|
||||||
|
"deprecated and will be removed in v22.9.",
|
||||||
|
22.9,
|
||||||
|
)
|
||||||
|
file_or_directory = cast(str, file_or_directory.decode())
|
||||||
|
file_or_directory = Path(file_or_directory)
|
||||||
|
|
||||||
|
if directory_handler and (directory_view or index):
|
||||||
|
raise ValueError(
|
||||||
|
"When explicitly setting directory_handler, you cannot "
|
||||||
|
"set either directory_view or index. Instead, pass "
|
||||||
|
"these arguments to your DirectoryHandler instance."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not directory_handler:
|
||||||
|
directory_handler = DirectoryHandler(
|
||||||
|
uri=uri,
|
||||||
|
directory=file_or_directory,
|
||||||
|
directory_view=directory_view,
|
||||||
|
index=index,
|
||||||
|
)
|
||||||
|
|
||||||
|
static = FutureStatic(
|
||||||
|
uri,
|
||||||
|
file_or_directory,
|
||||||
|
pattern,
|
||||||
|
use_modified_since,
|
||||||
|
use_content_range,
|
||||||
|
stream_large_files,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
strict_slashes,
|
||||||
|
content_type,
|
||||||
|
resource_type,
|
||||||
|
directory_handler,
|
||||||
|
)
|
||||||
|
self._future_statics.add(static)
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
self._apply_static(static)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticHandleMixin(metaclass=SanicMeta):
|
||||||
|
def _apply_static(self, static: FutureStatic) -> Route:
|
||||||
|
return self._register_static(static)
|
||||||
|
|
||||||
|
def _register_static(
|
||||||
|
self,
|
||||||
|
static: FutureStatic,
|
||||||
|
):
|
||||||
|
# TODO: Though sanic is not a file server, I feel like we should
|
||||||
|
# at least make a good effort here. Modified-since is nice, but
|
||||||
|
# we could also look into etags, expires, and caching
|
||||||
|
"""
|
||||||
|
Register a static directory handler with Sanic by adding a route to the
|
||||||
|
router and registering a handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if isinstance(static.file_or_directory, bytes):
|
||||||
|
file_or_directory = static.file_or_directory.decode("utf-8")
|
||||||
|
elif isinstance(static.file_or_directory, PurePath):
|
||||||
|
file_or_directory = str(static.file_or_directory)
|
||||||
|
elif not isinstance(static.file_or_directory, str):
|
||||||
|
raise ValueError("Invalid file path string.")
|
||||||
|
else:
|
||||||
|
file_or_directory = static.file_or_directory
|
||||||
|
|
||||||
|
uri = static.uri
|
||||||
|
name = static.name
|
||||||
|
# If we're not trying to match a file directly,
|
||||||
|
# serve from the folder
|
||||||
|
if not static.resource_type:
|
||||||
|
if not path.isfile(file_or_directory):
|
||||||
|
uri = uri.rstrip("/")
|
||||||
|
uri += "/<__file_uri__:path>"
|
||||||
|
elif static.resource_type == "dir":
|
||||||
|
if path.isfile(file_or_directory):
|
||||||
|
raise TypeError(
|
||||||
|
"Resource type improperly identified as directory. "
|
||||||
|
f"'{file_or_directory}'"
|
||||||
|
)
|
||||||
|
uri = uri.rstrip("/")
|
||||||
|
uri += "/<__file_uri__:path>"
|
||||||
|
elif static.resource_type == "file" and not path.isfile(
|
||||||
|
file_or_directory
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
"Resource type improperly identified as file. "
|
||||||
|
f"'{file_or_directory}'"
|
||||||
|
)
|
||||||
|
elif static.resource_type != "file":
|
||||||
|
raise ValueError(
|
||||||
|
"The resource_type should be set to 'file' or 'dir'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# special prefix for static files
|
||||||
|
# if not static.name.startswith("_static_"):
|
||||||
|
# name = f"_static_{static.name}"
|
||||||
|
|
||||||
|
_handler = wraps(self._static_request_handler)(
|
||||||
|
partial(
|
||||||
|
self._static_request_handler,
|
||||||
|
file_or_directory=file_or_directory,
|
||||||
|
use_modified_since=static.use_modified_since,
|
||||||
|
use_content_range=static.use_content_range,
|
||||||
|
stream_large_files=static.stream_large_files,
|
||||||
|
content_type=static.content_type,
|
||||||
|
directory_handler=static.directory_handler,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
route, _ = self.route( # type: ignore
|
||||||
|
uri=uri,
|
||||||
|
methods=["GET", "HEAD"],
|
||||||
|
name=name,
|
||||||
|
host=static.host,
|
||||||
|
strict_slashes=static.strict_slashes,
|
||||||
|
static=True,
|
||||||
|
)(_handler)
|
||||||
|
|
||||||
|
return route
|
||||||
|
|
||||||
|
async def _static_request_handler(
|
||||||
|
self,
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
file_or_directory: PathLike,
|
||||||
|
use_modified_since: bool,
|
||||||
|
use_content_range: bool,
|
||||||
|
stream_large_files: Union[bool, int],
|
||||||
|
directory_handler: DirectoryHandler,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
__file_uri__: Optional[str] = None,
|
||||||
|
):
|
||||||
|
not_found = FileNotFound(
|
||||||
|
"File not found",
|
||||||
|
path=file_or_directory,
|
||||||
|
relative_url=__file_uri__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge served directory and requested file if provided
|
||||||
|
file_path = await self._get_file_path(
|
||||||
|
file_or_directory, __file_uri__, not_found
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {}
|
||||||
|
# Check if the client has been sent this file before
|
||||||
|
# and it has not been modified since
|
||||||
|
stats = None
|
||||||
|
if use_modified_since:
|
||||||
|
stats = await stat_async(file_path)
|
||||||
|
modified_since = stats.st_mtime
|
||||||
|
response = await validate_file(request.headers, modified_since)
|
||||||
|
if response:
|
||||||
|
return response
|
||||||
|
headers["Last-Modified"] = formatdate(
|
||||||
|
modified_since, usegmt=True
|
||||||
|
)
|
||||||
|
_range = None
|
||||||
|
if use_content_range:
|
||||||
|
_range = None
|
||||||
|
if not stats:
|
||||||
|
stats = await stat_async(file_path)
|
||||||
|
headers["Accept-Ranges"] = "bytes"
|
||||||
|
headers["Content-Length"] = str(stats.st_size)
|
||||||
|
if request.method != "HEAD":
|
||||||
|
try:
|
||||||
|
_range = ContentRangeHandler(request, stats)
|
||||||
|
except HeaderNotFound:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
del headers["Content-Length"]
|
||||||
|
headers.update(_range.headers)
|
||||||
|
|
||||||
|
if "content-type" not in headers:
|
||||||
|
content_type = (
|
||||||
|
content_type
|
||||||
|
or guess_type(file_path)[0]
|
||||||
|
or DEFAULT_HTTP_CONTENT_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
|
if "charset=" not in content_type and (
|
||||||
|
content_type.startswith("text/")
|
||||||
|
or content_type == "application/javascript"
|
||||||
|
):
|
||||||
|
content_type += "; charset=utf-8"
|
||||||
|
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
|
||||||
|
if request.method == "HEAD":
|
||||||
|
return HTTPResponse(headers=headers)
|
||||||
|
else:
|
||||||
|
if stream_large_files:
|
||||||
|
if isinstance(stream_large_files, bool):
|
||||||
|
threshold = 1024 * 1024
|
||||||
|
else:
|
||||||
|
threshold = stream_large_files
|
||||||
|
|
||||||
|
if not stats:
|
||||||
|
stats = await stat_async(file_path)
|
||||||
|
if stats.st_size >= threshold:
|
||||||
|
return await file_stream(
|
||||||
|
file_path, headers=headers, _range=_range
|
||||||
|
)
|
||||||
|
return await file(file_path, headers=headers, _range=_range)
|
||||||
|
except (IsADirectoryError, PermissionError):
|
||||||
|
return await directory_handler.handle(request, request.path)
|
||||||
|
except RangeNotSatisfiable:
|
||||||
|
raise
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise not_found
|
||||||
|
except Exception:
|
||||||
|
error_logger.exception(
|
||||||
|
"Exception in static request handler: "
|
||||||
|
f"path={file_or_directory}, "
|
||||||
|
f"relative_url={__file_uri__}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _get_file_path(self, file_or_directory, __file_uri__, not_found):
|
||||||
|
file_path_raw = Path(unquote(file_or_directory))
|
||||||
|
root_path = file_path = file_path_raw.resolve()
|
||||||
|
|
||||||
|
if __file_uri__:
|
||||||
|
# Strip all / that in the beginning of the URL to help prevent
|
||||||
|
# python from herping a derp and treating the uri as an
|
||||||
|
# absolute path
|
||||||
|
unquoted_file_uri = unquote(__file_uri__).lstrip("/")
|
||||||
|
file_path_raw = Path(file_or_directory, unquoted_file_uri)
|
||||||
|
file_path = file_path_raw.resolve()
|
||||||
|
if (
|
||||||
|
file_path < root_path and not file_path_raw.is_symlink()
|
||||||
|
) or ".." in file_path_raw.parts:
|
||||||
|
error_logger.exception(
|
||||||
|
f"File not found: path={file_or_directory}, "
|
||||||
|
f"relative_url={__file_uri__}"
|
||||||
|
)
|
||||||
|
raise not_found
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_path.relative_to(root_path)
|
||||||
|
except ValueError:
|
||||||
|
if not file_path_raw.is_symlink():
|
||||||
|
error_logger.exception(
|
||||||
|
f"File not found: path={file_or_directory}, "
|
||||||
|
f"relative_url={__file_uri__}"
|
||||||
|
)
|
||||||
|
raise not_found
|
||||||
|
return file_path
|
|
@ -1,6 +1,7 @@
|
||||||
from pathlib import PurePath
|
from pathlib import Path
|
||||||
from typing import Dict, Iterable, List, NamedTuple, Optional, Union
|
from typing import Dict, Iterable, List, NamedTuple, Optional, Union
|
||||||
|
|
||||||
|
from sanic.handlers.directory import DirectoryHandler
|
||||||
from sanic.models.handler_types import (
|
from sanic.models.handler_types import (
|
||||||
ErrorMiddlewareType,
|
ErrorMiddlewareType,
|
||||||
ListenerType,
|
ListenerType,
|
||||||
|
@ -46,16 +47,17 @@ class FutureException(NamedTuple):
|
||||||
|
|
||||||
class FutureStatic(NamedTuple):
|
class FutureStatic(NamedTuple):
|
||||||
uri: str
|
uri: str
|
||||||
file_or_directory: Union[str, bytes, PurePath]
|
file_or_directory: Path
|
||||||
pattern: str
|
pattern: str
|
||||||
use_modified_since: bool
|
use_modified_since: bool
|
||||||
use_content_range: bool
|
use_content_range: bool
|
||||||
stream_large_files: bool
|
stream_large_files: Union[bool, int]
|
||||||
name: str
|
name: str
|
||||||
host: Optional[str]
|
host: Optional[str]
|
||||||
strict_slashes: Optional[bool]
|
strict_slashes: Optional[bool]
|
||||||
content_type: Optional[bool]
|
content_type: Optional[str]
|
||||||
resource_type: Optional[str]
|
resource_type: Optional[str]
|
||||||
|
directory_handler: DirectoryHandler
|
||||||
|
|
||||||
|
|
||||||
class FutureSignal(NamedTuple):
|
class FutureSignal(NamedTuple):
|
||||||
|
|
0
sanic/pages/__init__.py
Normal file
0
sanic/pages/__init__.py
Normal file
51
sanic/pages/base.py
Normal file
51
sanic/pages/base.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from html5tagger import HTML, Document
|
||||||
|
|
||||||
|
from sanic import __version__ as VERSION
|
||||||
|
from sanic.application.logo import SVG_LOGO
|
||||||
|
from sanic.pages.css import CSS
|
||||||
|
|
||||||
|
|
||||||
|
class BasePage(ABC, metaclass=CSS): # no cov
|
||||||
|
TITLE = "Unknown"
|
||||||
|
CSS: str
|
||||||
|
|
||||||
|
def __init__(self, debug: bool = True) -> None:
|
||||||
|
self.doc = Document(self.TITLE, lang="en")
|
||||||
|
self.debug = debug
|
||||||
|
|
||||||
|
@property
|
||||||
|
def style(self) -> str:
|
||||||
|
return self.CSS
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
self._head()
|
||||||
|
self._body()
|
||||||
|
self._foot()
|
||||||
|
return str(self.doc)
|
||||||
|
|
||||||
|
def _head(self) -> None:
|
||||||
|
self.doc.style(HTML(self.style))
|
||||||
|
with self.doc.header:
|
||||||
|
self.doc.div(self.TITLE)
|
||||||
|
|
||||||
|
def _foot(self) -> None:
|
||||||
|
with self.doc.footer:
|
||||||
|
self.doc.div("powered by")
|
||||||
|
with self.doc.div:
|
||||||
|
self._sanic_logo()
|
||||||
|
if self.debug:
|
||||||
|
self.doc.div(f"Version {VERSION}")
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _body(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def _sanic_logo(self) -> None:
|
||||||
|
self.doc.a(
|
||||||
|
HTML(SVG_LOGO),
|
||||||
|
href="https://sanic.dev",
|
||||||
|
target="_blank",
|
||||||
|
referrerpolicy="no-referrer",
|
||||||
|
)
|
35
sanic/pages/css.py
Normal file
35
sanic/pages/css.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from abc import ABCMeta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
CURRENT_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_style(maybe_style: Optional[str], name: str) -> str:
|
||||||
|
if maybe_style is not None:
|
||||||
|
maybe_path = Path(maybe_style)
|
||||||
|
if maybe_path.exists():
|
||||||
|
return maybe_path.read_text(encoding="UTF-8")
|
||||||
|
return maybe_style
|
||||||
|
maybe_path = CURRENT_DIR / "styles" / f"{name}.css"
|
||||||
|
if maybe_path.exists():
|
||||||
|
return maybe_path.read_text(encoding="UTF-8")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class CSS(ABCMeta):
|
||||||
|
"""Cascade stylesheets, i.e. combine all ancestor styles"""
|
||||||
|
|
||||||
|
def __new__(cls, name, bases, attrs):
|
||||||
|
Page = super().__new__(cls, name, bases, attrs)
|
||||||
|
# Use a locally defined STYLE or the one from styles directory
|
||||||
|
s = _extract_style(attrs.get("STYLE"), name)
|
||||||
|
Page.STYLE = f"\n/* {name} */\n{s.strip()}\n" if s else ""
|
||||||
|
# Combine with all ancestor styles
|
||||||
|
Page.CSS = "".join(
|
||||||
|
Class.STYLE
|
||||||
|
for Class in reversed(Page.__mro__)
|
||||||
|
if type(Class) is CSS
|
||||||
|
)
|
||||||
|
return Page
|
66
sanic/pages/directory_page.py
Normal file
66
sanic/pages/directory_page.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from typing import Dict, Iterable
|
||||||
|
|
||||||
|
from html5tagger import E
|
||||||
|
|
||||||
|
from .base import BasePage
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info < (3, 8): # no cov
|
||||||
|
FileInfo = Dict
|
||||||
|
|
||||||
|
else:
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
class FileInfo(TypedDict):
|
||||||
|
icon: str
|
||||||
|
file_name: str
|
||||||
|
file_access: str
|
||||||
|
file_size: str
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryPage(BasePage): # no cov
|
||||||
|
TITLE = "Directory Viewer"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, files: Iterable[FileInfo], url: str, debug: bool
|
||||||
|
) -> None:
|
||||||
|
super().__init__(debug)
|
||||||
|
self.files = files
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def _body(self) -> None:
|
||||||
|
with self.doc.main:
|
||||||
|
self._headline()
|
||||||
|
files = list(self.files)
|
||||||
|
if files:
|
||||||
|
self._file_table(files)
|
||||||
|
else:
|
||||||
|
self.doc.p("The folder is empty.")
|
||||||
|
|
||||||
|
def _headline(self):
|
||||||
|
"""Implement a heading with the current path, combined with
|
||||||
|
breadcrumb links"""
|
||||||
|
with self.doc.h1(id="breadcrumbs"):
|
||||||
|
p = self.url.split("/")[:-1]
|
||||||
|
|
||||||
|
for i, part in enumerate(p):
|
||||||
|
path = "/".join(p[: i + 1]) + "/"
|
||||||
|
with self.doc.a(href=path):
|
||||||
|
self.doc.span(part, class_="dir").span("/", class_="sep")
|
||||||
|
|
||||||
|
def _file_table(self, files: Iterable[FileInfo]):
|
||||||
|
with self.doc.table(class_="autoindex container"):
|
||||||
|
for f in files:
|
||||||
|
self._file_row(**f)
|
||||||
|
|
||||||
|
def _file_row(
|
||||||
|
self,
|
||||||
|
icon: str,
|
||||||
|
file_name: str,
|
||||||
|
file_access: str,
|
||||||
|
file_size: str,
|
||||||
|
):
|
||||||
|
first = E.span(icon, class_="icon").a(file_name, href=file_name)
|
||||||
|
self.doc.tr.td(first).td(file_size).td(file_access)
|
79
sanic/pages/styles/BasePage.css
Normal file
79
sanic/pages/styles/BasePage.css
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
html {
|
||||||
|
font: 16px sans-serif;
|
||||||
|
background: #eee;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body>* {
|
||||||
|
padding: 1rem 2vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
body>* {
|
||||||
|
padding: 0.5rem 1.5vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
min-width: 600px;
|
||||||
|
max-width: 1600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: #111;
|
||||||
|
color: #e1e1e1;
|
||||||
|
border-bottom: 1px solid #272727;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #88f;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo {
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.icon {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
background: #111;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
62
sanic/pages/styles/DirectoryPage.css
Normal file
62
sanic/pages/styles/DirectoryPage.css
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
#breadcrumbs>a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#breadcrumbs>a .dir {
|
||||||
|
padding: 0 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#breadcrumbs>a:first-child:hover::before,
|
||||||
|
#breadcrumbs>a .dir:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#breadcrumbs>a:first-child::before {
|
||||||
|
content: "🏠";
|
||||||
|
}
|
||||||
|
|
||||||
|
#breadcrumbs>a:last-child {
|
||||||
|
color: #ff0d68;
|
||||||
|
}
|
||||||
|
|
||||||
|
main a {
|
||||||
|
color: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex {
|
||||||
|
width: 100%;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex tr {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex tr:hover {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex td {
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex td:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex td:nth-child(2) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.autoindex td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
table.autoindex tr:hover {
|
||||||
|
background-color: #222;
|
||||||
|
}
|
||||||
|
}
|
|
@ -146,7 +146,6 @@ class Request:
|
||||||
head: bytes = b"",
|
head: bytes = b"",
|
||||||
stream_id: int = 0,
|
stream_id: int = 0,
|
||||||
):
|
):
|
||||||
|
|
||||||
self.raw_url = url_bytes
|
self.raw_url = url_bytes
|
||||||
try:
|
try:
|
||||||
self._parsed_url = parse_url(url_bytes)
|
self._parsed_url = parse_url(url_bytes)
|
||||||
|
|
|
@ -94,7 +94,6 @@ def watchdog(sleep_interval, reload_dirs):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|
||||||
changed = set()
|
changed = set()
|
||||||
for filename in itertools.chain(
|
for filename in itertools.chain(
|
||||||
_iter_module_files(),
|
_iter_module_files(),
|
||||||
|
|
|
@ -45,7 +45,7 @@ class WebSocketConnection:
|
||||||
|
|
||||||
await self._send(message)
|
await self._send(message)
|
||||||
|
|
||||||
async def recv(self, *args, **kwargs) -> Optional[str]:
|
async def recv(self, *args, **kwargs) -> Optional[Union[str, bytes]]:
|
||||||
message = await self._receive()
|
message = await self._receive()
|
||||||
|
|
||||||
if message["type"] == "websocket.receive":
|
if message["type"] == "websocket.receive":
|
||||||
|
@ -53,7 +53,7 @@ class WebSocketConnection:
|
||||||
return message["text"]
|
return message["text"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
try:
|
try:
|
||||||
return message["bytes"].decode()
|
return message["bytes"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise InvalidUsage("Bad ASGI message received")
|
raise InvalidUsage("Bad ASGI message received")
|
||||||
elif message["type"] == "websocket.disconnect":
|
elif message["type"] == "websocket.disconnect":
|
||||||
|
|
|
@ -52,7 +52,6 @@ class WebsocketFrameAssembler:
|
||||||
paused: bool
|
paused: bool
|
||||||
|
|
||||||
def __init__(self, protocol) -> None:
|
def __init__(self, protocol) -> None:
|
||||||
|
|
||||||
self.protocol = protocol
|
self.protocol = protocol
|
||||||
|
|
||||||
self.read_mutex = asyncio.Lock()
|
self.read_mutex = asyncio.Lock()
|
||||||
|
|
|
@ -686,7 +686,6 @@ class WebsocketImplProtocol:
|
||||||
:raises TypeError: for unsupported inputs
|
:raises TypeError: for unsupported inputs
|
||||||
"""
|
"""
|
||||||
async with self.conn_mutex:
|
async with self.conn_mutex:
|
||||||
|
|
||||||
if self.ws_proto.state in (CLOSED, CLOSING):
|
if self.ws_proto.state in (CLOSED, CLOSING):
|
||||||
raise WebsocketClosed(
|
raise WebsocketClosed(
|
||||||
"Cannot write to websocket interface after it is closed."
|
"Cannot write to websocket interface after it is closed."
|
||||||
|
|
|
@ -2,7 +2,6 @@ from pathlib import Path
|
||||||
|
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
from sanic.exceptions import SanicException
|
from sanic.exceptions import SanicException
|
||||||
from sanic.response import redirect
|
|
||||||
|
|
||||||
|
|
||||||
def create_simple_server(directory: Path):
|
def create_simple_server(directory: Path):
|
||||||
|
@ -12,10 +11,8 @@ def create_simple_server(directory: Path):
|
||||||
)
|
)
|
||||||
|
|
||||||
app = Sanic("SimpleServer")
|
app = Sanic("SimpleServer")
|
||||||
app.static("/", directory, name="main")
|
app.static(
|
||||||
|
"/", directory, name="main", directory_view=True, index="index.html"
|
||||||
@app.get("/")
|
)
|
||||||
def index(_):
|
|
||||||
return redirect(app.url_for("main", filename="index.html"))
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
@ -11,7 +11,6 @@ class TouchUpMeta(SanicMeta):
|
||||||
methods = attrs.get("__touchup__")
|
methods = attrs.get("__touchup__")
|
||||||
attrs["__touched__"] = False
|
attrs["__touched__"] = False
|
||||||
if methods:
|
if methods:
|
||||||
|
|
||||||
for method in methods:
|
for method in methods:
|
||||||
if method not in attrs:
|
if method not in attrs:
|
||||||
raise SanicException(
|
raise SanicException(
|
||||||
|
|
|
@ -75,7 +75,6 @@ def load_module_from_file_location(
|
||||||
location = location.decode(encoding)
|
location = location.decode(encoding)
|
||||||
|
|
||||||
if isinstance(location, Path) or "/" in location or "$" in location:
|
if isinstance(location, Path) or "/" in location or "$" in location:
|
||||||
|
|
||||||
if not isinstance(location, Path):
|
if not isinstance(location, Path):
|
||||||
# A) Check if location contains any environment variables
|
# A) Check if location contains any environment variables
|
||||||
# in format ${some_env_var}.
|
# in format ${some_env_var}.
|
||||||
|
|
5
setup.py
5
setup.py
|
@ -35,6 +35,7 @@ def open_local(paths, mode="r", encoding="utf8"):
|
||||||
|
|
||||||
return codecs.open(path, mode, encoding)
|
return codecs.open(path, mode, encoding)
|
||||||
|
|
||||||
|
|
||||||
def str_to_bool(val: str) -> bool:
|
def str_to_bool(val: str) -> bool:
|
||||||
val = val.lower()
|
val = val.lower()
|
||||||
if val in {
|
if val in {
|
||||||
|
@ -55,6 +56,7 @@ def str_to_bool(val: str) -> bool:
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid truth value {val}")
|
raise ValueError(f"Invalid truth value {val}")
|
||||||
|
|
||||||
|
|
||||||
with open_local(["sanic", "__version__.py"], encoding="latin1") as fp:
|
with open_local(["sanic", "__version__.py"], encoding="latin1") as fp:
|
||||||
try:
|
try:
|
||||||
version = re.findall(
|
version = re.findall(
|
||||||
|
@ -79,7 +81,7 @@ setup_kwargs = {
|
||||||
),
|
),
|
||||||
"long_description": long_description,
|
"long_description": long_description,
|
||||||
"packages": find_packages(exclude=("tests", "tests.*")),
|
"packages": find_packages(exclude=("tests", "tests.*")),
|
||||||
"package_data": {"sanic": ["py.typed"]},
|
"package_data": {"sanic": ["py.typed", "pages/styles/*"]},
|
||||||
"platforms": "any",
|
"platforms": "any",
|
||||||
"python_requires": ">=3.7",
|
"python_requires": ">=3.7",
|
||||||
"classifiers": [
|
"classifiers": [
|
||||||
|
@ -109,6 +111,7 @@ requirements = [
|
||||||
"aiofiles>=0.6.0",
|
"aiofiles>=0.6.0",
|
||||||
"websockets>=10.0",
|
"websockets>=10.0",
|
||||||
"multidict>=5.0,<7.0",
|
"multidict>=5.0,<7.0",
|
||||||
|
"html5tagger>=1.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
tests_require = [
|
tests_require = [
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
|
@ -58,7 +60,6 @@ CACHE: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
class RouteStringGenerator:
|
class RouteStringGenerator:
|
||||||
|
|
||||||
ROUTE_COUNT_PER_DEPTH = 100
|
ROUTE_COUNT_PER_DEPTH = 100
|
||||||
HTTP_METHODS = HTTP_METHODS
|
HTTP_METHODS = HTTP_METHODS
|
||||||
ROUTE_PARAM_TYPES = ["str", "int", "float", "alpha", "uuid"]
|
ROUTE_PARAM_TYPES = ["str", "int", "float", "alpha", "uuid"]
|
||||||
|
@ -232,3 +233,12 @@ def urlopen():
|
||||||
urlopen.read = Mock()
|
urlopen.read = Mock()
|
||||||
with patch("sanic.cli.inspector_client.urlopen", urlopen):
|
with patch("sanic.cli.inspector_client.urlopen", urlopen):
|
||||||
yield urlopen
|
yield urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def static_file_directory():
|
||||||
|
"""The static directory to serve"""
|
||||||
|
current_file = inspect.getfile(inspect.currentframe())
|
||||||
|
current_directory = os.path.dirname(os.path.abspath(current_file))
|
||||||
|
static_directory = os.path.join(current_directory, "static")
|
||||||
|
return static_directory
|
||||||
|
|
|
@ -36,6 +36,7 @@ def test_app_loop_running(app: Sanic):
|
||||||
assert response.text == "pass"
|
assert response.text == "pass"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_create_asyncio_server(app: Sanic):
|
def test_create_asyncio_server(app: Sanic):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
asyncio_srv_coro = app.create_server(return_asyncio_server=True)
|
asyncio_srv_coro = app.create_server(return_asyncio_server=True)
|
||||||
|
@ -44,6 +45,7 @@ def test_create_asyncio_server(app: Sanic):
|
||||||
assert srv.is_serving() is True
|
assert srv.is_serving() is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_asyncio_server_no_start_serving(app: Sanic):
|
def test_asyncio_server_no_start_serving(app: Sanic):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
asyncio_srv_coro = app.create_server(
|
asyncio_srv_coro = app.create_server(
|
||||||
|
@ -55,6 +57,7 @@ def test_asyncio_server_no_start_serving(app: Sanic):
|
||||||
assert srv.is_serving() is False
|
assert srv.is_serving() is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_asyncio_server_start_serving(app: Sanic):
|
def test_asyncio_server_start_serving(app: Sanic):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
asyncio_srv_coro = app.create_server(
|
asyncio_srv_coro = app.create_server(
|
||||||
|
@ -72,6 +75,7 @@ def test_asyncio_server_start_serving(app: Sanic):
|
||||||
# Looks like we can't easily test `serve_forever()`
|
# Looks like we can't easily test `serve_forever()`
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_create_server_main(app: Sanic, caplog):
|
def test_create_server_main(app: Sanic, caplog):
|
||||||
app.listener("main_process_start")(lambda *_: ...)
|
app.listener("main_process_start")(lambda *_: ...)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
@ -86,6 +90,7 @@ def test_create_server_main(app: Sanic, caplog):
|
||||||
) in caplog.record_tuples
|
) in caplog.record_tuples
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_create_server_no_startup(app: Sanic):
|
def test_create_server_no_startup(app: Sanic):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
asyncio_srv_coro = app.create_server(
|
asyncio_srv_coro = app.create_server(
|
||||||
|
@ -101,6 +106,7 @@ def test_create_server_no_startup(app: Sanic):
|
||||||
loop.run_until_complete(srv.start_serving())
|
loop.run_until_complete(srv.start_serving())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_create_server_main_convenience(app: Sanic, caplog):
|
def test_create_server_main_convenience(app: Sanic, caplog):
|
||||||
app.main_process_start(lambda *_: ...)
|
app.main_process_start(lambda *_: ...)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
@ -126,7 +132,6 @@ def test_app_loop_not_running(app: Sanic):
|
||||||
|
|
||||||
|
|
||||||
def test_app_run_raise_type_error(app: Sanic):
|
def test_app_run_raise_type_error(app: Sanic):
|
||||||
|
|
||||||
with pytest.raises(TypeError) as excinfo:
|
with pytest.raises(TypeError) as excinfo:
|
||||||
app.run(loop="loop")
|
app.run(loop="loop")
|
||||||
|
|
||||||
|
@ -139,7 +144,6 @@ def test_app_run_raise_type_error(app: Sanic):
|
||||||
|
|
||||||
|
|
||||||
def test_app_route_raise_value_error(app: Sanic):
|
def test_app_route_raise_value_error(app: Sanic):
|
||||||
|
|
||||||
with pytest.raises(ValueError) as excinfo:
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
|
||||||
@app.route("/test")
|
@app.route("/test")
|
||||||
|
@ -221,7 +225,6 @@ def test_app_websocket_parameters(websocket_protocol_mock, app: Sanic):
|
||||||
|
|
||||||
|
|
||||||
def test_handle_request_with_nested_exception(app: Sanic, monkeypatch):
|
def test_handle_request_with_nested_exception(app: Sanic, monkeypatch):
|
||||||
|
|
||||||
err_msg = "Mock Exception"
|
err_msg = "Mock Exception"
|
||||||
|
|
||||||
def mock_error_handler_response(*args, **kwargs):
|
def mock_error_handler_response(*args, **kwargs):
|
||||||
|
@ -241,7 +244,6 @@ def test_handle_request_with_nested_exception(app: Sanic, monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def test_handle_request_with_nested_exception_debug(app: Sanic, monkeypatch):
|
def test_handle_request_with_nested_exception_debug(app: Sanic, monkeypatch):
|
||||||
|
|
||||||
err_msg = "Mock Exception"
|
err_msg = "Mock Exception"
|
||||||
|
|
||||||
def mock_error_handler_response(*args, **kwargs):
|
def mock_error_handler_response(*args, **kwargs):
|
||||||
|
@ -470,6 +472,7 @@ def test_uvloop_config(app: Sanic, monkeypatch, use):
|
||||||
try_use_uvloop.assert_not_called()
|
try_use_uvloop.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
|
def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
|
||||||
apps = (Sanic("default-uvloop"), Sanic("no-uvloop"), Sanic("yes-uvloop"))
|
apps = (Sanic("default-uvloop"), Sanic("no-uvloop"), Sanic("yes-uvloop"))
|
||||||
|
|
||||||
|
@ -506,6 +509,7 @@ def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
|
||||||
assert counter[(logging.WARNING, message)] == modified
|
assert counter[(logging.WARNING, message)] == modified
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
def test_multiple_uvloop_configs_display_warning(caplog):
|
def test_multiple_uvloop_configs_display_warning(caplog):
|
||||||
Sanic._uvloop_setting = None # Reset the setting (changed in prev tests)
|
Sanic._uvloop_setting = None # Reset the setting (changed in prev tests)
|
||||||
|
|
||||||
|
|
|
@ -342,7 +342,7 @@ async def test_websocket_send(send, receive, message_stack):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_websocket_receive(send, receive, message_stack):
|
async def test_websocket_text_receive(send, receive, message_stack):
|
||||||
msg = {"text": "hello", "type": "websocket.receive"}
|
msg = {"text": "hello", "type": "websocket.receive"}
|
||||||
message_stack.append(msg)
|
message_stack.append(msg)
|
||||||
|
|
||||||
|
@ -351,6 +351,15 @@ async def test_websocket_receive(send, receive, message_stack):
|
||||||
|
|
||||||
assert text == msg["text"]
|
assert text == msg["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_websocket_bytes_receive(send, receive, message_stack):
|
||||||
|
msg = {"bytes": b"hello", "type": "websocket.receive"}
|
||||||
|
message_stack.append(msg)
|
||||||
|
|
||||||
|
ws = WebSocketConnection(send, receive)
|
||||||
|
data = await ws.receive()
|
||||||
|
|
||||||
|
assert data == msg["bytes"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_websocket_accept_with_no_subprotocols(
|
async def test_websocket_accept_with_no_subprotocols(
|
||||||
|
|
|
@ -148,7 +148,6 @@ def test_cookie_set_unknown_property():
|
||||||
|
|
||||||
|
|
||||||
def test_cookie_set_same_key(app):
|
def test_cookie_set_same_key(app):
|
||||||
|
|
||||||
cookies = {"test": "wait"}
|
cookies = {"test": "wait"}
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
@ -62,7 +62,6 @@ def exception_handler_app():
|
||||||
|
|
||||||
@exception_handler_app.route("/8", error_format="html")
|
@exception_handler_app.route("/8", error_format="html")
|
||||||
def handler_8(request):
|
def handler_8(request):
|
||||||
|
|
||||||
raise ErrorWithRequestCtx("OK")
|
raise ErrorWithRequestCtx("OK")
|
||||||
|
|
||||||
@exception_handler_app.exception(ErrorWithRequestCtx, NotFound)
|
@exception_handler_app.exception(ErrorWithRequestCtx, NotFound)
|
||||||
|
@ -214,7 +213,7 @@ def test_error_handler_noisy_log(
|
||||||
exception_handler_app: Sanic, monkeypatch: MonkeyPatch
|
exception_handler_app: Sanic, monkeypatch: MonkeyPatch
|
||||||
):
|
):
|
||||||
err_logger = Mock()
|
err_logger = Mock()
|
||||||
monkeypatch.setattr(handlers, "error_logger", err_logger)
|
monkeypatch.setattr(handlers.error, "error_logger", err_logger)
|
||||||
|
|
||||||
exception_handler_app.config["NOISY_EXCEPTIONS"] = False
|
exception_handler_app.config["NOISY_EXCEPTIONS"] = False
|
||||||
exception_handler_app.test_client.get("/1")
|
exception_handler_app.test_client.get("/1")
|
||||||
|
|
|
@ -514,7 +514,6 @@ def test_file_stream_head_response(
|
||||||
def test_file_stream_response_range(
|
def test_file_stream_response_range(
|
||||||
app: Sanic, file_name, static_file_directory, size, start, end
|
app: Sanic, file_name, static_file_directory, size, start, end
|
||||||
):
|
):
|
||||||
|
|
||||||
Range = namedtuple("Range", ["size", "start", "end", "total"])
|
Range = namedtuple("Range", ["size", "start", "end", "total"])
|
||||||
total = len(get_file_content(static_file_directory, file_name))
|
total = len(get_file_content(static_file_directory, file_name))
|
||||||
range = Range(size=size, start=start, end=end, total=total)
|
range = Range(size=size, start=start, end=end, total=total)
|
||||||
|
|
|
@ -722,7 +722,6 @@ def test_add_webscoket_route_with_version(app):
|
||||||
|
|
||||||
|
|
||||||
def test_route_duplicate(app):
|
def test_route_duplicate(app):
|
||||||
|
|
||||||
with pytest.raises(RouteExists):
|
with pytest.raises(RouteExists):
|
||||||
|
|
||||||
@app.route("/test")
|
@app.route("/test")
|
||||||
|
@ -819,7 +818,6 @@ def test_unquote_add_route(app, unquote):
|
||||||
|
|
||||||
|
|
||||||
def test_dynamic_add_route(app):
|
def test_dynamic_add_route(app):
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
async def handler(request, name):
|
async def handler(request, name):
|
||||||
|
@ -834,7 +832,6 @@ def test_dynamic_add_route(app):
|
||||||
|
|
||||||
|
|
||||||
def test_dynamic_add_route_string(app):
|
def test_dynamic_add_route_string(app):
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
async def handler(request, name):
|
async def handler(request, name):
|
||||||
|
@ -938,7 +935,6 @@ def test_dynamic_add_route_unhashable(app):
|
||||||
|
|
||||||
|
|
||||||
def test_add_route_duplicate(app):
|
def test_add_route_duplicate(app):
|
||||||
|
|
||||||
with pytest.raises(RouteExists):
|
with pytest.raises(RouteExists):
|
||||||
|
|
||||||
async def handler1(request):
|
async def handler1(request):
|
||||||
|
@ -1120,7 +1116,6 @@ def test_route_raise_ParameterNameConflicts(app):
|
||||||
|
|
||||||
|
|
||||||
def test_route_invalid_host(app):
|
def test_route_invalid_host(app):
|
||||||
|
|
||||||
host = 321
|
host = 321
|
||||||
with pytest.raises(ValueError) as excinfo:
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
|
||||||
|
|
|
@ -93,6 +93,7 @@ def test_dont_register_system_signals(app):
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="windows cannot SIGINT processes")
|
@pytest.mark.skipif(os.name == "nt", reason="windows cannot SIGINT processes")
|
||||||
def test_windows_workaround():
|
def test_windows_workaround():
|
||||||
"""Test Windows workaround (on any other OS)"""
|
"""Test Windows workaround (on any other OS)"""
|
||||||
|
|
||||||
# At least some code coverage, even though this test doesn't work on
|
# At least some code coverage, even though this test doesn't work on
|
||||||
# Windows...
|
# Windows...
|
||||||
class MockApp:
|
class MockApp:
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import inspect
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
@ -13,15 +12,6 @@ from sanic import Sanic, text
|
||||||
from sanic.exceptions import FileNotFound
|
from sanic.exceptions import FileNotFound
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def static_file_directory():
|
|
||||||
"""The static directory to serve"""
|
|
||||||
current_file = inspect.getfile(inspect.currentframe())
|
|
||||||
current_directory = os.path.dirname(os.path.abspath(current_file))
|
|
||||||
static_directory = os.path.join(current_directory, "static")
|
|
||||||
return static_directory
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def double_dotted_directory_file(static_file_directory: str):
|
def double_dotted_directory_file(static_file_directory: str):
|
||||||
"""Generate double dotted directory and its files"""
|
"""Generate double dotted directory and its files"""
|
||||||
|
@ -118,7 +108,12 @@ def test_static_file_pathlib(app, static_file_directory, file_name):
|
||||||
def test_static_file_bytes(app, static_file_directory, file_name):
|
def test_static_file_bytes(app, static_file_directory, file_name):
|
||||||
bsep = os.path.sep.encode("utf-8")
|
bsep = os.path.sep.encode("utf-8")
|
||||||
file_path = static_file_directory.encode("utf-8") + bsep + file_name
|
file_path = static_file_directory.encode("utf-8") + bsep + file_name
|
||||||
app.static("/testing.file", file_path)
|
message = (
|
||||||
|
"Serving a static directory with a bytes "
|
||||||
|
"string is deprecated and will be removed in v22.9."
|
||||||
|
)
|
||||||
|
with pytest.warns(DeprecationWarning, match=message):
|
||||||
|
app.static("/testing.file", file_path)
|
||||||
request, response = app.test_client.get("/testing.file")
|
request, response = app.test_client.get("/testing.file")
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
|
|
||||||
|
@ -431,7 +426,6 @@ def test_static_stream_large_file(
|
||||||
"file_name", ["test.file", "decode me.txt", "python.png"]
|
"file_name", ["test.file", "decode me.txt", "python.png"]
|
||||||
)
|
)
|
||||||
def test_use_modified_since(app, static_file_directory, file_name):
|
def test_use_modified_since(app, static_file_directory, file_name):
|
||||||
|
|
||||||
file_stat = os.stat(get_file_path(static_file_directory, file_name))
|
file_stat = os.stat(get_file_path(static_file_directory, file_name))
|
||||||
modified_since = strftime(
|
modified_since = strftime(
|
||||||
"%a, %d %b %Y %H:%M:%S GMT", gmtime(file_stat.st_mtime)
|
"%a, %d %b %Y %H:%M:%S GMT", gmtime(file_stat.st_mtime)
|
||||||
|
|
123
tests/test_static_directory.py
Normal file
123
tests/test_static_directory.py
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sanic import Sanic
|
||||||
|
from sanic.handlers.directory import DirectoryHandler
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_path(static_file_directory, file_name):
|
||||||
|
return os.path.join(static_file_directory, file_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_content(static_file_directory, file_name):
|
||||||
|
"""The content of the static file to check"""
|
||||||
|
with open(get_file_path(static_file_directory, file_name), "rb") as file:
|
||||||
|
return file.read()
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_directory_view(app: Sanic, static_file_directory: str):
|
||||||
|
app.static("/static", static_file_directory, directory_view=True)
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.content_type == "text/html; charset=utf-8"
|
||||||
|
assert "<title>Directory Viewer</title>" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_index_single(app: Sanic, static_file_directory: str):
|
||||||
|
app.static("/static", static_file_directory, index="test.html")
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.body == get_file_content(
|
||||||
|
static_file_directory, "test.html"
|
||||||
|
)
|
||||||
|
assert response.headers["Content-Type"] == "text/html"
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_index_single_not_found(app: Sanic, static_file_directory: str):
|
||||||
|
app.static("/static", static_file_directory, index="index.html")
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/")
|
||||||
|
assert response.status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_index_multiple(app: Sanic, static_file_directory: str):
|
||||||
|
app.static(
|
||||||
|
"/static",
|
||||||
|
static_file_directory,
|
||||||
|
index=["index.html", "test.html"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.body == get_file_content(
|
||||||
|
static_file_directory, "test.html"
|
||||||
|
)
|
||||||
|
assert response.headers["Content-Type"] == "text/html"
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_directory_view_and_index(
|
||||||
|
app: Sanic, static_file_directory: str
|
||||||
|
):
|
||||||
|
app.static(
|
||||||
|
"/static",
|
||||||
|
static_file_directory,
|
||||||
|
directory_view=True,
|
||||||
|
index="foo.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/nested/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.content_type == "text/html; charset=utf-8"
|
||||||
|
assert "<title>Directory Viewer</title>" in response.text
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/nested/dir/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.body == get_file_content(
|
||||||
|
f"{static_file_directory}/nested/dir", "foo.txt"
|
||||||
|
)
|
||||||
|
assert response.content_type == "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_directory_handler(app: Sanic, static_file_directory: str):
|
||||||
|
dh = DirectoryHandler(
|
||||||
|
"/static",
|
||||||
|
Path(static_file_directory),
|
||||||
|
directory_view=True,
|
||||||
|
index="foo.txt",
|
||||||
|
)
|
||||||
|
app.static("/static", static_file_directory, directory_handler=dh)
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/nested/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.content_type == "text/html; charset=utf-8"
|
||||||
|
assert "<title>Directory Viewer</title>" in response.text
|
||||||
|
|
||||||
|
_, response = app.test_client.get("/static/nested/dir/")
|
||||||
|
assert response.status == 200
|
||||||
|
assert response.body == get_file_content(
|
||||||
|
f"{static_file_directory}/nested/dir", "foo.txt"
|
||||||
|
)
|
||||||
|
assert response.content_type == "text/plain"
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_directory_handler_fails(app: Sanic):
|
||||||
|
dh = DirectoryHandler(
|
||||||
|
"/static",
|
||||||
|
Path(""),
|
||||||
|
directory_view=True,
|
||||||
|
index="foo.txt",
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"When explicitly setting directory_handler, you cannot "
|
||||||
|
"set either directory_view or index. Instead, pass "
|
||||||
|
"these arguments to your DirectoryHandler instance."
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
app.static("/static", "", directory_handler=dh, directory_view=True)
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
app.static("/static", "", directory_handler=dh, index="index.html")
|
|
@ -654,7 +654,6 @@ def test_sanic_ssl_context_create():
|
||||||
reason="This test requires fork context",
|
reason="This test requires fork context",
|
||||||
)
|
)
|
||||||
def test_ssl_in_multiprocess_mode(app: Sanic, caplog):
|
def test_ssl_in_multiprocess_mode(app: Sanic, caplog):
|
||||||
|
|
||||||
ssl_dict = {"cert": localhost_cert, "key": localhost_key}
|
ssl_dict = {"cert": localhost_cert, "key": localhost_key}
|
||||||
event = Event()
|
event = Event()
|
||||||
|
|
||||||
|
|
|
@ -176,7 +176,6 @@ def handler(request: Request):
|
||||||
|
|
||||||
async def client(app: Sanic, loop: AbstractEventLoop):
|
async def client(app: Sanic, loop: AbstractEventLoop):
|
||||||
try:
|
try:
|
||||||
|
|
||||||
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
|
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
|
||||||
async with httpx.AsyncClient(transport=transport) as client:
|
async with httpx.AsyncClient(transport=transport) as client:
|
||||||
r = await client.get("http://myhost.invalid/")
|
r = await client.get("http://myhost.invalid/")
|
||||||
|
|
|
@ -83,7 +83,6 @@ def test_simple_url_for_getting_with_more_params(app, args, url):
|
||||||
|
|
||||||
|
|
||||||
def test_url_for_with_server_name(app):
|
def test_url_for_with_server_name(app):
|
||||||
|
|
||||||
server_name = f"{test_host}:{test_port}"
|
server_name = f"{test_host}:{test_port}"
|
||||||
app.config.update({"SERVER_NAME": server_name})
|
app.config.update({"SERVER_NAME": server_name})
|
||||||
path = "/myurl"
|
path = "/myurl"
|
||||||
|
|
|
@ -38,7 +38,6 @@ def test_load_module_from_file_location_with_non_existing_env_variable():
|
||||||
LoadFileException,
|
LoadFileException,
|
||||||
match="The following environment variables are not set: MuuMilk",
|
match="The following environment variables are not set: MuuMilk",
|
||||||
):
|
):
|
||||||
|
|
||||||
load_module_from_file_location("${MuuMilk}")
|
load_module_from_file_location("${MuuMilk}")
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user