Compare commits
	
		
			12 Commits
		
	
	
		
			v22.12.0
			...
			breaking-c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | c695c5250a | ||
|   | 23c1eaab29 | ||
|   | a5d7d03413 | ||
|   | 259e458847 | ||
|   | cb49c2b26d | ||
|   | dfc0704831 | ||
|   | d238995f1b | ||
|   | 6f5303e080 | ||
|   | 5e7f6998bd | ||
|   | c7a71cd00c | ||
|   | 9cb9e88678 | ||
|   | 30c53b6857 | 
| @@ -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,7 @@ module = [ | |||||||
|     "trustme.*", |     "trustme.*", | ||||||
|     "sanic_routing.*", |     "sanic_routing.*", | ||||||
|     "aioquic.*", |     "aioquic.*", | ||||||
|  |     "html5tagger.*", | ||||||
|  |     "tracerite.*", | ||||||
| ] | ] | ||||||
| ignore_missing_imports = true | ignore_missing_imports = true | ||||||
|   | |||||||
| @@ -1 +1,10 @@ | |||||||
| __version__ = "22.12.0" | __version__ = "23.3.0" | ||||||
|  | __compatibility__ = "22.12" | ||||||
|  |  | ||||||
|  | from inspect import currentframe, stack | ||||||
|  |  | ||||||
|  | for frame_info in stack(): | ||||||
|  |     if frame_info.frame is not currentframe(): | ||||||
|  |         value = frame_info.frame.f_globals.get("__SANIC_COMPATIBILITY__") | ||||||
|  |         if value: | ||||||
|  |             __compatibility__ = value | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								sanic/app.py
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								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, | ||||||
| @@ -878,6 +875,8 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta): | |||||||
|         :param request: HTTP Request object |         :param request: HTTP Request object | ||||||
|         :return: Nothing |         :return: Nothing | ||||||
|         """ |         """ | ||||||
|  |         __tracebackhide__ = True | ||||||
|  |  | ||||||
|         await self.dispatch( |         await self.dispatch( | ||||||
|             "http.lifecycle.handle", |             "http.lifecycle.handle", | ||||||
|             inline=True, |             inline=True, | ||||||
| @@ -890,11 +889,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 +925,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 +998,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 +1007,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_SIMPLE = """<svg id=logo-simple viewBox="0 0 964 279"><desc>Sanic</desc><path d="M107 222c9-2 10-20 1-22s-20-2-30-2-17 7-16 14 6 10 15 10h30zm115-1c16-2 30-11 35-23s6-24 2-33-6-14-15-20-24-11-38-10c-7 3-10 13-5 19s17-1 24 4 15 14 13 24-5 15-14 18-50 0-74 0h-17c-6 4-10 15-4 20s16 2 23 3zM251 83q9-1 9-7 0-15-10-16h-13c-10 6-10 20 0 22zM147 60c-4 0-10 3-11 11s5 13 10 12 42 0 67 0c5-3 7-10 6-15s-4-8-9-8zm-33 1c-8 0-16 0-24 3s-20 10-25 20-6 24-4 36 15 22 26 27 78 8 94 3c4-4 4-12 0-18s-69 8-93-10c-8-7-9-23 0-30s12-10 20-10 12 2 16-3 1-15-5-18z" fill="#ff0d68"/><path d="M676 74c0-14-18-9-20 0s0 30 0 39 20 9 20 2zm-297-10c-12 2-15 12-23 23l-41 58H340l22-30c8-12 23-13 30-4s20 24 24 38-10 10-17 10l-68 2q-17 1-48 30c-7 6-10 20 0 24s15-8 20-13 20 -20 58-21h50 c20 2 33 9 52 30 8 10 24-4 16-13L384 65q-3-2-5-1zm131 0c-10 1-12 12-11 20v96c1 10-3 23 5 32s20-5 17-15c0-23-3-46 2-67 5-12 22-14 32-5l103 87c7 5 19 1 18-9v-64c-3-10-20-9-21 2s-20 22-30 13l-97-80c-5-4-10-10-18-10zM701 76v128c2 10 15 12 20 4s0-102 0-124s-20-18-20-7z M850 63c-35 0-69-2-86 15s-20 60-13 66 13 8 16 0 1-10 1-27 12-26 20-32 66-5 85-5 31 4 31-10-18-7-54-7M764 159c-6-2-15-2-16 12s19 37 33 43 23 8 25-4-4-11-11-14q-9-3-22-18c-4-7-3-16-10-19zM828 196c-4 0-8 1-10 5s-4 12 0 15 8 2 12 2h60c5 0 10-2 12-6 3-7-1-16-8-16z" fill="#1f1f1f"/></svg>"""  # noqa | ||||||
|  |  | ||||||
| ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") | 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) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								sanic/breaking/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								sanic/breaking/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | from sanic.__version__ import __compatibility__ | ||||||
|  |  | ||||||
|  | if __compatibility__ == "22.12": | ||||||
|  |     from .v22_12.request import ( | ||||||
|  |         File, | ||||||
|  |         Request, | ||||||
|  |         RequestParameters, | ||||||
|  |         parse_multipart_form, | ||||||
|  |     ) | ||||||
|  | elif __compatibility__ == "23.3": | ||||||
|  |     from .v23_3.request import ( | ||||||
|  |         File, | ||||||
|  |         Request, | ||||||
|  |         RequestParameters, | ||||||
|  |         parse_multipart_form, | ||||||
|  |     ) | ||||||
|  | else: | ||||||
|  |     raise RuntimeError(f"Unknown compatibility value: {__compatibility__}") | ||||||
							
								
								
									
										0
									
								
								sanic/breaking/v22_12/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								sanic/breaking/v22_12/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										1123
									
								
								sanic/breaking/v22_12/request.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1123
									
								
								sanic/breaking/v22_12/request.py
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										0
									
								
								sanic/breaking/v23_3/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								sanic/breaking/v23_3/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										9
									
								
								sanic/breaking/v23_3/request.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								sanic/breaking/v23_3/request.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | from ..v22_12.request import File | ||||||
|  | from ..v22_12.request import Request as LegacyRequest | ||||||
|  | from ..v22_12.request import RequestParameters, parse_multipart_form | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Request(LegacyRequest): | ||||||
|  |     @property | ||||||
|  |     def something_new(self): | ||||||
|  |         return 123 | ||||||
| @@ -88,6 +88,12 @@ class Header(CIMultiDict): | |||||||
|     very similar to a regular dictionary. |     very similar to a regular dictionary. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     def __getattr__(self, key: str) -> str: | ||||||
|  |         if key.startswith("_"): | ||||||
|  |             return self.__getattribute__(key) | ||||||
|  |         key = key.rstrip("_").replace("_", "-") | ||||||
|  |         return ",".join(self.getall(key, default=[])) | ||||||
|  |  | ||||||
|     def get_all(self, key: str): |     def get_all(self, key: str): | ||||||
|         """ |         """ | ||||||
|         Convenience method mapped to ``getall()``. |         Convenience method mapped to ``getall()``. | ||||||
|   | |||||||
| @@ -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,9 @@ 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.log import deprecation, logger | ||||||
| from sanic.response import HTTPResponse, html, json, text | from sanic.pages.error import ErrorPage | ||||||
|  | from sanic.response import html, json, text | ||||||
|  |  | ||||||
|  |  | ||||||
| dumps: t.Callable[..., str] | dumps: t.Callable[..., str] | ||||||
| @@ -33,13 +35,15 @@ 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 = """\ | ||||||
|     "The server encountered an internal error and " | The application encountered an unexpected error and could not continue.\ | ||||||
|     "cannot complete your request." | """ | ||||||
| ) |  | ||||||
| FALLBACK_STATUS = 500 | FALLBACK_STATUS = 500 | ||||||
|  | JSON = "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseRenderer: | class BaseRenderer: | ||||||
| @@ -113,134 +117,18 @@ class HTMLRenderer(BaseRenderer): | |||||||
|     The default fallback type. |     The default fallback type. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     TRACEBACK_STYLE = """ |  | ||||||
|         html { font-family: sans-serif } |  | ||||||
|         h2 { color: #888; } |  | ||||||
|         .tb-wrapper p, dl, dd { margin: 0 } |  | ||||||
|         .frame-border { margin: 1rem } |  | ||||||
|         .frame-line > *, dt, dd { padding: 0.3rem 0.6rem } |  | ||||||
|         .frame-line, dl { margin-bottom: 0.3rem } |  | ||||||
|         .frame-code, dd { font-size: 16px; padding-left: 4ch } |  | ||||||
|         .tb-wrapper, dl { border: 1px solid #eee } |  | ||||||
|         .tb-header,.obj-header { |  | ||||||
|             background: #eee; padding: 0.3rem; font-weight: bold |  | ||||||
|         } |  | ||||||
|         .frame-descriptor, dt { background: #e2eafb; font-size: 14px } |  | ||||||
|     """ |  | ||||||
|     TRACEBACK_WRAPPER_HTML = ( |  | ||||||
|         "<div class=tb-header>{exc_name}: {exc_value}</div>" |  | ||||||
|         "<div class=tb-wrapper>{frame_html}</div>" |  | ||||||
|     ) |  | ||||||
|     TRACEBACK_BORDER = ( |  | ||||||
|         "<div class=frame-border>" |  | ||||||
|         "The above exception was the direct cause of the following exception:" |  | ||||||
|         "</div>" |  | ||||||
|     ) |  | ||||||
|     TRACEBACK_LINE_HTML = ( |  | ||||||
|         "<div class=frame-line>" |  | ||||||
|         "<p class=frame-descriptor>" |  | ||||||
|         "File {0.filename}, line <i>{0.lineno}</i>, " |  | ||||||
|         "in <code><b>{0.name}</b></code>" |  | ||||||
|         "<p class=frame-code><code>{0.line}</code>" |  | ||||||
|         "</div>" |  | ||||||
|     ) |  | ||||||
|     OBJECT_WRAPPER_HTML = ( |  | ||||||
|         "<div class=obj-header>{title}</div>" |  | ||||||
|         "<dl class={obj_type}>{display_html}</dl>" |  | ||||||
|     ) |  | ||||||
|     OBJECT_DISPLAY_HTML = "<dt>{key}</dt><dd><code>{value}</code></dd>" |  | ||||||
|     OUTPUT_HTML = ( |  | ||||||
|         "<!DOCTYPE html><html lang=en>" |  | ||||||
|         "<meta charset=UTF-8><title>{title}</title>\n" |  | ||||||
|         "<style>{style}</style>\n" |  | ||||||
|         "<h1>{title}</h1><p>{text}\n" |  | ||||||
|         "{body}" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     def full(self) -> HTTPResponse: |     def full(self) -> HTTPResponse: | ||||||
|         return html( |         page = ErrorPage( | ||||||
|             self.OUTPUT_HTML.format( |             debug=self.debug, | ||||||
|                 title=self.title, |             title=super().title, | ||||||
|                 text=self.text, |             text=super().text, | ||||||
|                 style=self.TRACEBACK_STYLE, |             request=self.request, | ||||||
|                 body=self._generate_body(full=True), |             exc=self.exception, | ||||||
|             ), |  | ||||||
|             status=self.status, |  | ||||||
|         ) |         ) | ||||||
|  |         return html(page.render(), status=self.status, headers=self.headers) | ||||||
|  |  | ||||||
|     def minimal(self) -> HTTPResponse: |     def minimal(self) -> HTTPResponse: | ||||||
|         return html( |         return self.full() | ||||||
|             self.OUTPUT_HTML.format( |  | ||||||
|                 title=self.title, |  | ||||||
|                 text=self.text, |  | ||||||
|                 style=self.TRACEBACK_STYLE, |  | ||||||
|                 body=self._generate_body(full=False), |  | ||||||
|             ), |  | ||||||
|             status=self.status, |  | ||||||
|             headers=self.headers, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def text(self): |  | ||||||
|         return escape(super().text) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def title(self): |  | ||||||
|         return escape(f"⚠️ {super().title}") |  | ||||||
|  |  | ||||||
|     def _generate_body(self, *, full): |  | ||||||
|         lines = [] |  | ||||||
|         if full: |  | ||||||
|             _, exc_value, __ = sys.exc_info() |  | ||||||
|             exceptions = [] |  | ||||||
|             while exc_value: |  | ||||||
|                 exceptions.append(self._format_exc(exc_value)) |  | ||||||
|                 exc_value = exc_value.__cause__ |  | ||||||
|  |  | ||||||
|             traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions)) |  | ||||||
|             appname = escape(self.request.app.name) |  | ||||||
|             name = escape(self.exception.__class__.__name__) |  | ||||||
|             value = escape(self.exception) |  | ||||||
|             path = escape(self.request.path) |  | ||||||
|             lines += [ |  | ||||||
|                 f"<h2>Traceback of {appname} " "(most recent call last):</h2>", |  | ||||||
|                 f"{traceback_html}", |  | ||||||
|                 "<div class=summary><p>", |  | ||||||
|                 f"<b>{name}: {value}</b> " |  | ||||||
|                 f"while handling path <code>{path}</code>", |  | ||||||
|                 "</div>", |  | ||||||
|             ] |  | ||||||
|  |  | ||||||
|         for attr, display in (("context", True), ("extra", bool(full))): |  | ||||||
|             info = getattr(self.exception, attr, None) |  | ||||||
|             if info and display: |  | ||||||
|                 lines.append(self._generate_object_display(info, attr)) |  | ||||||
|  |  | ||||||
|         return "\n".join(lines) |  | ||||||
|  |  | ||||||
|     def _generate_object_display( |  | ||||||
|         self, obj: t.Dict[str, t.Any], descriptor: str |  | ||||||
|     ) -> str: |  | ||||||
|         display = "".join( |  | ||||||
|             self.OBJECT_DISPLAY_HTML.format(key=key, value=value) |  | ||||||
|             for key, value in obj.items() |  | ||||||
|         ) |  | ||||||
|         return self.OBJECT_WRAPPER_HTML.format( |  | ||||||
|             title=descriptor.title(), |  | ||||||
|             display_html=display, |  | ||||||
|             obj_type=descriptor.lower(), |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def _format_exc(self, exc): |  | ||||||
|         frames = extract_tb(exc.__traceback__) |  | ||||||
|         frame_html = "".join( |  | ||||||
|             self.TRACEBACK_LINE_HTML.format(frame) for frame in frames |  | ||||||
|         ) |  | ||||||
|         return self.TRACEBACK_WRAPPER_HTML.format( |  | ||||||
|             exc_name=escape(exc.__class__.__name__), |  | ||||||
|             exc_value=escape(exc), |  | ||||||
|             frame_html=frame_html, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TextRenderer(BaseRenderer): | class TextRenderer(BaseRenderer): | ||||||
| @@ -388,32 +276,26 @@ def escape(text): | |||||||
|     return f"{text}".replace("&", "&").replace("<", "<") |     return f"{text}".replace("&", "&").replace("<", "<") | ||||||
|  |  | ||||||
|  |  | ||||||
| RENDERERS_BY_CONFIG = { | MIME_BY_CONFIG = { | ||||||
|     "html": HTMLRenderer, |     "text": "text/plain", | ||||||
|     "json": JSONRenderer, |     "json": "application/json", | ||||||
|     "text": TextRenderer, |     "html": "text/html", | ||||||
| } | } | ||||||
|  | CONFIG_BY_MIME = {v: k for k, v in MIME_BY_CONFIG.items()} | ||||||
| RENDERERS_BY_CONTENT_TYPE = { | RENDERERS_BY_CONTENT_TYPE = { | ||||||
|     "text/plain": TextRenderer, |     "text/plain": TextRenderer, | ||||||
|     "application/json": JSONRenderer, |     "application/json": JSONRenderer, | ||||||
|     "multipart/form-data": HTMLRenderer, |     "multipart/form-data": HTMLRenderer, | ||||||
|     "text/html": HTMLRenderer, |     "text/html": HTMLRenderer, | ||||||
| } | } | ||||||
| CONTENT_TYPE_BY_RENDERERS = { |  | ||||||
|     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", | ||||||
| @@ -421,7 +303,7 @@ RESPONSE_MAPPING = { | |||||||
|  |  | ||||||
|  |  | ||||||
| def check_error_format(format): | def check_error_format(format): | ||||||
|     if format not in RENDERERS_BY_CONFIG and format != "auto": |     if format not in MIME_BY_CONFIG and format != "auto": | ||||||
|         raise SanicException(f"Unknown format: {format}") |         raise SanicException(f"Unknown format: {format}") | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -436,98 +318,68 @@ def exception_response( | |||||||
|     """ |     """ | ||||||
|     Render a response for the default FALLBACK exception handler. |     Render a response for the default FALLBACK exception handler. | ||||||
|     """ |     """ | ||||||
|     content_type = None |  | ||||||
|  |  | ||||||
|     if not renderer: |     if not renderer: | ||||||
|         # Make sure we have something set |         mt = guess_mime(request, fallback) | ||||||
|         renderer = base |         renderer = RENDERERS_BY_CONTENT_TYPE.get(mt, base) | ||||||
|         render_format = fallback |  | ||||||
|  |  | ||||||
|         if request: |  | ||||||
|             # If there is a request, try and get the format |  | ||||||
|             # from the route |  | ||||||
|             if request.route: |  | ||||||
|                 try: |  | ||||||
|                     if request.route.extra.error_format: |  | ||||||
|                         render_format = request.route.extra.error_format |  | ||||||
|                 except AttributeError: |  | ||||||
|                     ... |  | ||||||
|  |  | ||||||
|             content_type = request.headers.getone("content-type", "").split( |  | ||||||
|                 ";" |  | ||||||
|             )[0] |  | ||||||
|  |  | ||||||
|             acceptable = request.accept |  | ||||||
|  |  | ||||||
|             # If the format is auto still, make a guess |  | ||||||
|             if render_format == "auto": |  | ||||||
|                 # First, if there is an Accept header, check if text/html |  | ||||||
|                 # is the first option |  | ||||||
|                 # According to MDN Web Docs, all major browsers use text/html |  | ||||||
|                 # as the primary value in Accept (with the exception of IE 8, |  | ||||||
|                 # and, well, if you are supporting IE 8, then you have bigger |  | ||||||
|                 # problems to concern yourself with than what default exception |  | ||||||
|                 # renderer is used) |  | ||||||
|                 # Source: |  | ||||||
|                 # https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values |  | ||||||
|  |  | ||||||
|                 if acceptable and acceptable[0].match( |  | ||||||
|                     "text/html", |  | ||||||
|                     allow_type_wildcard=False, |  | ||||||
|                     allow_subtype_wildcard=False, |  | ||||||
|                 ): |  | ||||||
|                     renderer = HTMLRenderer |  | ||||||
|  |  | ||||||
|                 # Second, if there is an Accept header, check if |  | ||||||
|                 # application/json is an option, or if the content-type |  | ||||||
|                 # is application/json |  | ||||||
|                 elif ( |  | ||||||
|                     acceptable |  | ||||||
|                     and acceptable.match( |  | ||||||
|                         "application/json", |  | ||||||
|                         allow_type_wildcard=False, |  | ||||||
|                         allow_subtype_wildcard=False, |  | ||||||
|                     ) |  | ||||||
|                     or content_type == "application/json" |  | ||||||
|                 ): |  | ||||||
|                     renderer = JSONRenderer |  | ||||||
|  |  | ||||||
|                 # Third, if there is no Accept header, assume we want text. |  | ||||||
|                 # The likely use case here is a raw socket. |  | ||||||
|                 elif not acceptable: |  | ||||||
|                     renderer = TextRenderer |  | ||||||
|                 else: |  | ||||||
|                     # Fourth, look to see if there was a JSON body |  | ||||||
|                     # When in this situation, the request is probably coming |  | ||||||
|                     # from curl, an API client like Postman or Insomnia, or a |  | ||||||
|                     # package like requests or httpx |  | ||||||
|                     try: |  | ||||||
|                         # Give them the benefit of the doubt if they did: |  | ||||||
|                         # $ curl localhost:8000 -d '{"foo": "bar"}' |  | ||||||
|                         # And provide them with JSONRenderer |  | ||||||
|                         renderer = JSONRenderer if request.json else base |  | ||||||
|                     except BadRequest: |  | ||||||
|                         renderer = base |  | ||||||
|             else: |  | ||||||
|                 renderer = RENDERERS_BY_CONFIG.get(render_format, renderer) |  | ||||||
|  |  | ||||||
|             # Lastly, if there is an Accept header, make sure |  | ||||||
|             # our choice is okay |  | ||||||
|             if acceptable: |  | ||||||
|                 type_ = CONTENT_TYPE_BY_RENDERERS.get(renderer)  # type: ignore |  | ||||||
|                 if type_ and type_ not in acceptable: |  | ||||||
|                     # If the renderer selected is not in the Accept header |  | ||||||
|                     # look through what is in the Accept header, and select |  | ||||||
|                     # the first option that matches. Otherwise, just drop back |  | ||||||
|                     # to the original default |  | ||||||
|                     for accept in acceptable: |  | ||||||
|                         mtype = f"{accept.type_}/{accept.subtype}" |  | ||||||
|                         maybe = RENDERERS_BY_CONTENT_TYPE.get(mtype) |  | ||||||
|                         if maybe: |  | ||||||
|                             renderer = maybe |  | ||||||
|                             break |  | ||||||
|                     else: |  | ||||||
|                         renderer = base |  | ||||||
|  |  | ||||||
|     renderer = t.cast(t.Type[BaseRenderer], renderer) |     renderer = t.cast(t.Type[BaseRenderer], renderer) | ||||||
|     return renderer(request, exception, debug).render() |     return renderer(request, exception, debug).render() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def guess_mime(req: Request, fallback: str) -> str: | ||||||
|  |     # Attempt to find a suitable MIME format for the response. | ||||||
|  |     # Insertion-ordered map of formats["html"] = "source of that suggestion" | ||||||
|  |     formats = {} | ||||||
|  |     name = "" | ||||||
|  |     # Route error_format (by magic from handler code if auto, the default) | ||||||
|  |     if req.route: | ||||||
|  |         name = req.route.name | ||||||
|  |         f = req.route.extra.error_format | ||||||
|  |         if f in MIME_BY_CONFIG: | ||||||
|  |             formats[f] = name | ||||||
|  |  | ||||||
|  |     if not formats and fallback in MIME_BY_CONFIG: | ||||||
|  |         formats[fallback] = "FALLBACK_ERROR_FORMAT" | ||||||
|  |  | ||||||
|  |     # If still not known, check for the request for clues of JSON | ||||||
|  |     if not formats and fallback == "auto" and req.accept.match(JSON): | ||||||
|  |         if JSON in req.accept:  # Literally, not wildcard | ||||||
|  |             formats["json"] = "request.accept" | ||||||
|  |         elif JSON in req.headers.getone("content-type", ""): | ||||||
|  |             formats["json"] = "content-type" | ||||||
|  |         # DEPRECATION: Remove this block in 24.3 | ||||||
|  |         else: | ||||||
|  |             c = None | ||||||
|  |             try: | ||||||
|  |                 c = req.json | ||||||
|  |             except BadRequest: | ||||||
|  |                 pass | ||||||
|  |             if c: | ||||||
|  |                 formats["json"] = "request.json" | ||||||
|  |                 deprecation( | ||||||
|  |                     "Response type was determined by the JSON content of " | ||||||
|  |                     "the request. This behavior is deprecated and will be " | ||||||
|  |                     "removed in v24.3. Please specify the format either by\n" | ||||||
|  |                     f'  error_format="json" on route {name}, by\n' | ||||||
|  |                     '  FALLBACK_ERROR_FORMAT = "json", or by adding header\n' | ||||||
|  |                     "  accept: application/json to your requests.", | ||||||
|  |                     24.3, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |     # Any other supported formats | ||||||
|  |     if fallback == "auto": | ||||||
|  |         for k in MIME_BY_CONFIG: | ||||||
|  |             if k not in formats: | ||||||
|  |                 formats[k] = "any" | ||||||
|  |  | ||||||
|  |     mimes = [MIME_BY_CONFIG[k] for k in formats] | ||||||
|  |     m = req.accept.match(*mimes) | ||||||
|  |     if m: | ||||||
|  |         format = CONFIG_BY_MIME[m.mime] | ||||||
|  |         source = formats[format] | ||||||
|  |         logger.debug( | ||||||
|  |             f"The client accepts {m.header}, using '{format}' from {source}" | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         logger.debug(f"No format found, the client accepts {req.accept!r}") | ||||||
|  |     return m.mime | ||||||
|   | |||||||
							
								
								
									
										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 |  | ||||||
							
								
								
									
										378
									
								
								sanic/headers.py
									
									
									
									
									
								
							
							
						
						
									
										378
									
								
								sanic/headers.py
									
									
									
									
									
								
							| @@ -19,7 +19,6 @@ OptionsIterable = Iterable[Tuple[str, str]]  # May contain duplicate keys | |||||||
|  |  | ||||||
| _token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"' | _token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"' | ||||||
| _param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII) | _param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII) | ||||||
| _firefox_quote_escape = re.compile(r'\\"(?!; |\s*$)') |  | ||||||
| _ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}" | _ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}" | ||||||
| _ipv6_re = re.compile(_ipv6) | _ipv6_re = re.compile(_ipv6) | ||||||
| _host_re = re.compile( | _host_re = re.compile( | ||||||
| @@ -33,143 +32,96 @@ _host_re = re.compile( | |||||||
| # For more information, consult ../tests/test_requests.py | # For more information, consult ../tests/test_requests.py | ||||||
|  |  | ||||||
|  |  | ||||||
| def parse_arg_as_accept(f): | class MediaType: | ||||||
|     def func(self, other, *args, **kwargs): |     """A media type, as used in the Accept header.""" | ||||||
|         if not isinstance(other, Accept) and other: |  | ||||||
|             other = Accept.parse(other) |  | ||||||
|         return f(self, other, *args, **kwargs) |  | ||||||
|  |  | ||||||
|     return func |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MediaType(str): |  | ||||||
|     def __new__(cls, value: str): |  | ||||||
|         return str.__new__(cls, value) |  | ||||||
|  |  | ||||||
|     def __init__(self, value: str) -> None: |  | ||||||
|         self.value = value |  | ||||||
|         self.is_wildcard = self.check_if_wildcard(value) |  | ||||||
|  |  | ||||||
|     def __eq__(self, other): |  | ||||||
|         if self.is_wildcard: |  | ||||||
|             return True |  | ||||||
|  |  | ||||||
|         if self.match(other): |  | ||||||
|             return True |  | ||||||
|  |  | ||||||
|         other_is_wildcard = ( |  | ||||||
|             other.is_wildcard |  | ||||||
|             if isinstance(other, MediaType) |  | ||||||
|             else self.check_if_wildcard(other) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         return other_is_wildcard |  | ||||||
|  |  | ||||||
|     def match(self, other): |  | ||||||
|         other_value = other.value if isinstance(other, MediaType) else other |  | ||||||
|         return self.value == other_value |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def check_if_wildcard(value): |  | ||||||
|         return value == "*" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Accept(str): |  | ||||||
|     def __new__(cls, value: str, *args, **kwargs): |  | ||||||
|         return str.__new__(cls, value) |  | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         value: str, |         type_: str, | ||||||
|         type_: MediaType, |         subtype: str, | ||||||
|         subtype: MediaType, |         **params: str, | ||||||
|         *, |  | ||||||
|         q: str = "1.0", |  | ||||||
|         **kwargs: str, |  | ||||||
|     ): |     ): | ||||||
|         qvalue = float(q) |         self.type = type_ | ||||||
|         if qvalue > 1 or qvalue < 0: |  | ||||||
|             raise InvalidHeader( |  | ||||||
|                 f"Accept header qvalue must be between 0 and 1, not: {qvalue}" |  | ||||||
|             ) |  | ||||||
|         self.value = value |  | ||||||
|         self.type_ = type_ |  | ||||||
|         self.subtype = subtype |         self.subtype = subtype | ||||||
|         self.qvalue = qvalue |         self.q = float(params.get("q", "1.0")) | ||||||
|         self.params = kwargs |         self.params = params | ||||||
|  |         self.mime = f"{type_}/{subtype}" | ||||||
|  |         self.key = ( | ||||||
|  |             -1 * self.q, | ||||||
|  |             -1 * len(self.params), | ||||||
|  |             self.subtype == "*", | ||||||
|  |             self.type == "*", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def _compare(self, other, method): |     def __repr__(self): | ||||||
|         try: |         return self.mime + "".join(f";{k}={v}" for k, v in self.params.items()) | ||||||
|             return method(self.qvalue, other.qvalue) |  | ||||||
|         except (AttributeError, TypeError): |  | ||||||
|             return NotImplemented |  | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |     def __eq__(self, other): | ||||||
|     def __lt__(self, other: Union[str, Accept]): |         """Check for mime (str or MediaType) identical type/subtype. | ||||||
|         return self._compare(other, lambda s, o: s < o) |         Parameters such as q are not considered.""" | ||||||
|  |         if isinstance(other, str): | ||||||
|  |             # Give a friendly reminder if str contains parameters | ||||||
|  |             if ";" in other: | ||||||
|  |                 raise ValueError("Use match() to compare with parameters") | ||||||
|  |             return self.mime == other | ||||||
|  |         if isinstance(other, MediaType): | ||||||
|  |             # Ignore parameters silently with MediaType objects | ||||||
|  |             return self.mime == other.mime | ||||||
|  |         return NotImplemented | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |  | ||||||
|     def __le__(self, other: Union[str, Accept]): |  | ||||||
|         return self._compare(other, lambda s, o: s <= o) |  | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |  | ||||||
|     def __eq__(self, other: Union[str, Accept]):  # type: ignore |  | ||||||
|         return self._compare(other, lambda s, o: s == o) |  | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |  | ||||||
|     def __ge__(self, other: Union[str, Accept]): |  | ||||||
|         return self._compare(other, lambda s, o: s >= o) |  | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |  | ||||||
|     def __gt__(self, other: Union[str, Accept]): |  | ||||||
|         return self._compare(other, lambda s, o: s > o) |  | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |  | ||||||
|     def __ne__(self, other: Union[str, Accept]):  # type: ignore |  | ||||||
|         return self._compare(other, lambda s, o: s != o) |  | ||||||
|  |  | ||||||
|     @parse_arg_as_accept |  | ||||||
|     def match( |     def match( | ||||||
|         self, |         self, | ||||||
|         other, |         mime_with_params: Union[str, MediaType], | ||||||
|         *, |     ) -> Optional[MediaType]: | ||||||
|         allow_type_wildcard: bool = True, |         """Check if this media type matches the given mime type/subtype. | ||||||
|         allow_subtype_wildcard: bool = True, |         Wildcards are supported both ways on both type and subtype. | ||||||
|     ) -> bool: |         If mime contains a semicolon, optionally followed by parameters, | ||||||
|         type_match = ( |         the parameters of the two media types must match exactly. | ||||||
|             self.type_ == other.type_ |         Note:  Use the `==` operator instead to check for literal matches | ||||||
|             if allow_type_wildcard |         without expanding wildcards. | ||||||
|             else ( |         @param media_type: A type/subtype string to match. | ||||||
|                 self.type_.match(other.type_) |         @return `self` if the media types are compatible, else `None` | ||||||
|                 and not self.type_.is_wildcard |         """ | ||||||
|                 and not other.type_.is_wildcard |         mt = ( | ||||||
|             ) |             MediaType._parse(mime_with_params) | ||||||
|  |             if isinstance(mime_with_params, str) | ||||||
|  |             else mime_with_params | ||||||
|         ) |         ) | ||||||
|         subtype_match = ( |         return ( | ||||||
|             self.subtype == other.subtype |             self | ||||||
|             if allow_subtype_wildcard |             if ( | ||||||
|             else ( |                 mt | ||||||
|                 self.subtype.match(other.subtype) |                 # All parameters given in the other media type must match | ||||||
|                 and not self.subtype.is_wildcard |                 and all(self.params.get(k) == v for k, v in mt.params.items()) | ||||||
|                 and not other.subtype.is_wildcard |                 # Subtype match | ||||||
|  |                 and ( | ||||||
|  |                     self.subtype == mt.subtype | ||||||
|  |                     or self.subtype == "*" | ||||||
|  |                     or mt.subtype == "*" | ||||||
|  |                 ) | ||||||
|  |                 # Type match | ||||||
|  |                 and ( | ||||||
|  |                     self.type == mt.type or self.type == "*" or mt.type == "*" | ||||||
|  |                 ) | ||||||
|             ) |             ) | ||||||
|  |             else None | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         return type_match and subtype_match |     @property | ||||||
|  |     def has_wildcard(self) -> bool: | ||||||
|  |         """Return True if this media type has a wildcard in it.""" | ||||||
|  |         return any(part == "*" for part in (self.subtype, self.type)) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def parse(cls, raw: str) -> Accept: |     def _parse(cls, mime_with_params: str) -> Optional[MediaType]: | ||||||
|         invalid = False |         mtype = mime_with_params.strip() | ||||||
|         mtype = raw.strip() |         if "/" not in mime_with_params: | ||||||
|  |             return None | ||||||
|  |  | ||||||
|         try: |         mime, *raw_params = mtype.split(";") | ||||||
|             media, *raw_params = mtype.split(";") |         type_, subtype = mime.split("/", 1) | ||||||
|             type_, subtype = media.split("/") |         if not type_ or not subtype: | ||||||
|         except ValueError: |             raise ValueError(f"Invalid media type: {mtype}") | ||||||
|             invalid = True |  | ||||||
|  |  | ||||||
|         if invalid or not type_ or not subtype: |  | ||||||
|             raise InvalidHeader(f"Header contains invalid Accept value: {raw}") |  | ||||||
|  |  | ||||||
|         params = dict( |         params = dict( | ||||||
|             [ |             [ | ||||||
| @@ -178,46 +130,160 @@ class Accept(str): | |||||||
|             ] |             ] | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         return cls(mtype, MediaType(type_), MediaType(subtype), **params) |         return cls(type_.lstrip(), subtype.rstrip(), **params) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AcceptContainer(list): | class Matched: | ||||||
|     def __contains__(self, o: object) -> bool: |     """A matching result of a MIME string against a header.""" | ||||||
|         return any(item.match(o) for item in self) |  | ||||||
|  |  | ||||||
|     def match( |     def __init__(self, mime: str, header: Optional[MediaType]): | ||||||
|         self, |         self.mime = mime | ||||||
|         o: object, |         self.header = header | ||||||
|         *, |  | ||||||
|         allow_type_wildcard: bool = True, |     def __repr__(self): | ||||||
|         allow_subtype_wildcard: bool = True, |         return f"<{self} matched {self.header}>" if self else "<no match>" | ||||||
|     ) -> bool: |  | ||||||
|         return any( |     def __str__(self): | ||||||
|             item.match( |         return self.mime | ||||||
|                 o, |  | ||||||
|                 allow_type_wildcard=allow_type_wildcard, |     def __bool__(self): | ||||||
|                 allow_subtype_wildcard=allow_subtype_wildcard, |         return self.header is not None | ||||||
|  |  | ||||||
|  |     def __eq__(self, other: Any) -> bool: | ||||||
|  |         try: | ||||||
|  |             comp, other_accept = self._compare(other) | ||||||
|  |         except TypeError: | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         return bool( | ||||||
|  |             comp | ||||||
|  |             and ( | ||||||
|  |                 (self.header and other_accept.header) | ||||||
|  |                 or (not self.header and not other_accept.header) | ||||||
|             ) |             ) | ||||||
|             for item in self |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def _compare(self, other) -> Tuple[bool, Matched]: | ||||||
|  |         if isinstance(other, str): | ||||||
|  |             parsed = Matched.parse(other) | ||||||
|  |             if self.mime == other: | ||||||
|  |                 return True, parsed | ||||||
|  |             other = parsed | ||||||
|  |  | ||||||
|  |         if isinstance(other, Matched): | ||||||
|  |             return self.header == other.header, other | ||||||
|  |  | ||||||
|  |         raise TypeError( | ||||||
|  |             "Comparison not supported between unequal " | ||||||
|  |             f"mime types of '{self.mime}' and '{other}'" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def match(self, other: Union[str, Matched]) -> Optional[Matched]: | ||||||
|  |         accept = Matched.parse(other) if isinstance(other, str) else other | ||||||
|  |         if not self.header or not accept.header: | ||||||
|  |             return None | ||||||
|  |         if self.header.match(accept.header): | ||||||
|  |             return accept | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def parse(cls, raw: str) -> Matched: | ||||||
|  |         media_type = MediaType._parse(raw) | ||||||
|  |         return cls(raw, media_type) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AcceptList(list): | ||||||
|  |     """A list of media types, as used in the Accept header. | ||||||
|  |  | ||||||
|  |     The Accept header entries are listed in order of preference, starting | ||||||
|  |     with the most preferred. This class is a list of `MediaType` objects, | ||||||
|  |     that encapsulate also the q value or any other parameters. | ||||||
|  |  | ||||||
|  |     Two separate methods are provided for searching the list: | ||||||
|  |     - 'match' for finding the most preferred match (wildcards supported) | ||||||
|  |     -  operator 'in' for checking explicit matches (wildcards as literals) | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def match(self, *mimes: str, accept_wildcards=True) -> Matched: | ||||||
|  |         """Find a media type accepted by the client. | ||||||
|  |  | ||||||
|  |         This method can be used to find which of the media types requested by | ||||||
|  |         the client is most preferred against the ones given as arguments. | ||||||
|  |  | ||||||
|  |         The ordering of preference is set by: | ||||||
|  |         1. The order set by RFC 7231, s. 5.3.2, giving a higher priority | ||||||
|  |             to q values and more specific type definitions, | ||||||
|  |         2. The order of the arguments (first is most preferred), and | ||||||
|  |         3. The first matching entry on the Accept header. | ||||||
|  |  | ||||||
|  |         Wildcards are matched both ways. A match is usually found, as the | ||||||
|  |         Accept headers typically include `*/*`, in particular if the header | ||||||
|  |         is missing, is not manually set, or if the client is a browser. | ||||||
|  |  | ||||||
|  |         Note: the returned object behaves as a string of the mime argument | ||||||
|  |         that matched, and is empty/falsy if no match was found. The matched | ||||||
|  |         header entry `MediaType` or `None` is available as the `m` attribute. | ||||||
|  |  | ||||||
|  |         @param mimes: Any MIME types to search for in order of preference. | ||||||
|  |         @param accept_wildcards: Match Accept entries with wildcards in them. | ||||||
|  |         @return A match object with the mime string and the MediaType object. | ||||||
|  |         """ | ||||||
|  |         a = sorted( | ||||||
|  |             (-acc.q, i, j, mime, acc) | ||||||
|  |             for j, acc in enumerate(self) | ||||||
|  |             if accept_wildcards or not acc.has_wildcard | ||||||
|  |             for i, mime in enumerate(mimes) | ||||||
|  |             if acc.match(mime) | ||||||
|  |         ) | ||||||
|  |         return Matched(*(a[0][-2:] if a else ("", None))) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         """Format as Accept header value (parsed, not original).""" | ||||||
|  |         return ", ".join(str(m) for m in self) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def parse_accept(accept: Optional[str]) -> AcceptList: | ||||||
|  |     """Parse an Accept header and order the acceptable media types in | ||||||
|  |     according to RFC 7231, s. 5.3.2 | ||||||
|  |     https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2 | ||||||
|  |     """ | ||||||
|  |     if not accept: | ||||||
|  |         if accept == "": | ||||||
|  |             return AcceptList()  # Empty header, accept nothing | ||||||
|  |         accept = "*/*"  # No header means that all types are accepted | ||||||
|  |     try: | ||||||
|  |         a = [ | ||||||
|  |             mt | ||||||
|  |             for mt in [MediaType._parse(mtype) for mtype in accept.split(",")] | ||||||
|  |             if mt | ||||||
|  |         ] | ||||||
|  |         if not a: | ||||||
|  |             raise ValueError | ||||||
|  |         return AcceptList(sorted(a, key=lambda x: x.key)) | ||||||
|  |     except ValueError: | ||||||
|  |         raise InvalidHeader(f"Invalid header value in Accept: {accept}") | ||||||
|  |  | ||||||
|  |  | ||||||
| def parse_content_header(value: str) -> Tuple[str, Options]: | def parse_content_header(value: str) -> Tuple[str, Options]: | ||||||
|     """Parse content-type and content-disposition header values. |     """Parse content-type and content-disposition header values. | ||||||
|  |  | ||||||
|     E.g. 'form-data; name=upload; filename=\"file.txt\"' to |     E.g. `form-data; name=upload; filename="file.txt"` to | ||||||
|     ('form-data', {'name': 'upload', 'filename': 'file.txt'}) |     ('form-data', {'name': 'upload', 'filename': 'file.txt'}) | ||||||
|  |  | ||||||
|     Mostly identical to cgi.parse_header and werkzeug.parse_options_header |     Mostly identical to cgi.parse_header and werkzeug.parse_options_header | ||||||
|     but runs faster and handles special characters better. Unescapes quotes. |     but runs faster and handles special characters better. | ||||||
|  |  | ||||||
|  |     Unescapes %22 to `"` and %0D%0A to `\n` in field values. | ||||||
|     """ |     """ | ||||||
|     value = _firefox_quote_escape.sub("%22", value) |  | ||||||
|     pos = value.find(";") |     pos = value.find(";") | ||||||
|     if pos == -1: |     if pos == -1: | ||||||
|         options: Dict[str, Union[int, str]] = {} |         options: Dict[str, Union[int, str]] = {} | ||||||
|     else: |     else: | ||||||
|         options = { |         options = { | ||||||
|             m.group(1).lower(): m.group(2) or m.group(3).replace("%22", '"') |             m.group(1) | ||||||
|  |             .lower(): (m.group(2) or m.group(3)) | ||||||
|  |             .replace("%22", '"') | ||||||
|  |             .replace("%0D%0A", "\n") | ||||||
|             for m in _param.finditer(value[pos:]) |             for m in _param.finditer(value[pos:]) | ||||||
|         } |         } | ||||||
|         value = value[:pos] |         value = value[:pos] | ||||||
| @@ -368,34 +434,6 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes: | |||||||
|     return ret |     return ret | ||||||
|  |  | ||||||
|  |  | ||||||
| def _sort_accept_value(accept: Accept): |  | ||||||
|     return ( |  | ||||||
|         accept.qvalue, |  | ||||||
|         len(accept.params), |  | ||||||
|         accept.subtype != "*", |  | ||||||
|         accept.type_ != "*", |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def parse_accept(accept: str) -> AcceptContainer: |  | ||||||
|     """Parse an Accept header and order the acceptable media types in |  | ||||||
|     accorsing to RFC 7231, s. 5.3.2 |  | ||||||
|     https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2 |  | ||||||
|     """ |  | ||||||
|     media_types = accept.split(",") |  | ||||||
|     accept_list: List[Accept] = [] |  | ||||||
|  |  | ||||||
|     for mtype in media_types: |  | ||||||
|         if not mtype: |  | ||||||
|             continue |  | ||||||
|  |  | ||||||
|         accept_list.append(Accept.parse(mtype)) |  | ||||||
|  |  | ||||||
|     return AcceptContainer( |  | ||||||
|         sorted(accept_list, key=_sort_accept_value, reverse=True) |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def parse_credentials( | def parse_credentials( | ||||||
|     header: Optional[str], |     header: Optional[str], | ||||||
|     prefixes: Union[List, Tuple, Set] = None, |     prefixes: Union[List, Tuple, Set] = None, | ||||||
|   | |||||||
| @@ -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)) | ||||||
|   | |||||||
| @@ -877,7 +877,10 @@ class StartupMixin(metaclass=SanicMeta): | |||||||
|  |  | ||||||
|             sync_manager.shutdown() |             sync_manager.shutdown() | ||||||
|             for sock in socks: |             for sock in socks: | ||||||
|                 sock.shutdown(SHUT_RDWR) |                 try: | ||||||
|  |                     sock.shutdown(SHUT_RDWR) | ||||||
|  |                 except OSError: | ||||||
|  |                     ... | ||||||
|                 sock.close() |                 sock.close() | ||||||
|             socks = [] |             socks = [] | ||||||
|             trigger_events(main_stop, loop, primary) |             trigger_events(main_stop, loop, primary) | ||||||
| @@ -1109,7 +1112,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
									
								
							
							
								
								
									
										70
									
								
								sanic/pages/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								sanic/pages/base.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | from abc import ABC, abstractmethod | ||||||
|  |  | ||||||
|  | from html5tagger import HTML, Builder, Document | ||||||
|  |  | ||||||
|  | from sanic import __version__ as VERSION | ||||||
|  | from sanic.application.logo import SVG_LOGO_SIMPLE | ||||||
|  | from sanic.pages.css import CSS | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BasePage(ABC, metaclass=CSS):  # no cov | ||||||
|  |     TITLE = "Sanic" | ||||||
|  |     HEADING = None | ||||||
|  |     CSS: str | ||||||
|  |     doc: Builder | ||||||
|  |  | ||||||
|  |     def __init__(self, debug: bool = True) -> None: | ||||||
|  |         self.debug = debug | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def style(self) -> str: | ||||||
|  |         return self.CSS | ||||||
|  |  | ||||||
|  |     def render(self) -> str: | ||||||
|  |         self.doc = Document(self.TITLE, lang="en", id="sanic") | ||||||
|  |         self._head() | ||||||
|  |         self._body() | ||||||
|  |         self._foot() | ||||||
|  |         return str(self.doc) | ||||||
|  |  | ||||||
|  |     def _head(self) -> None: | ||||||
|  |         self.doc.style(HTML(self.style)) | ||||||
|  |         with self.doc.header: | ||||||
|  |             self.doc.div(self.HEADING or self.TITLE) | ||||||
|  |  | ||||||
|  |     def _foot(self) -> None: | ||||||
|  |         with self.doc.footer: | ||||||
|  |             self.doc.div("powered by") | ||||||
|  |             with self.doc.div: | ||||||
|  |                 self._sanic_logo() | ||||||
|  |             if self.debug: | ||||||
|  |                 self.doc.div(f"Version {VERSION}") | ||||||
|  |                 with self.doc.div: | ||||||
|  |                     for idx, (title, href) in enumerate( | ||||||
|  |                         ( | ||||||
|  |                             ("Docs", "https://sanic.dev"), | ||||||
|  |                             ("Help", "https://sanic.dev/en/help.html"), | ||||||
|  |                             ("GitHub", "https://github.com/sanic-org/sanic"), | ||||||
|  |                         ) | ||||||
|  |                     ): | ||||||
|  |                         if idx > 0: | ||||||
|  |                             self.doc(" | ") | ||||||
|  |                         self.doc.a( | ||||||
|  |                             title, | ||||||
|  |                             href=href, | ||||||
|  |                             target="_blank", | ||||||
|  |                             referrerpolicy="no-referrer", | ||||||
|  |                         ) | ||||||
|  |                 self.doc.div("DEBUG mode") | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def _body(self) -> None: | ||||||
|  |         ... | ||||||
|  |  | ||||||
|  |     def _sanic_logo(self) -> None: | ||||||
|  |         self.doc.a( | ||||||
|  |             HTML(SVG_LOGO_SIMPLE), | ||||||
|  |             href="https://sanic.dev", | ||||||
|  |             target="_blank", | ||||||
|  |             referrerpolicy="no-referrer", | ||||||
|  |         ) | ||||||
							
								
								
									
										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 | ||||||
|  |         Page.STYLE = _extract_style(attrs.get("STYLE_FILE"), name) | ||||||
|  |         Page.STYLE += attrs.get("STYLE_APPEND", "") | ||||||
|  |         # Combine with all ancestor styles | ||||||
|  |         Page.CSS = "".join( | ||||||
|  |             Class.STYLE | ||||||
|  |             for Class in reversed(Page.__mro__) | ||||||
|  |             if type(Class) is CSS | ||||||
|  |         ) | ||||||
|  |         return Page | ||||||
							
								
								
									
										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) | ||||||
							
								
								
									
										109
									
								
								sanic/pages/error.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								sanic/pages/error.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | |||||||
|  | from typing import Any, Mapping | ||||||
|  |  | ||||||
|  | import tracerite.html | ||||||
|  |  | ||||||
|  | from html5tagger import E | ||||||
|  | from tracerite import html_traceback, inspector | ||||||
|  |  | ||||||
|  | from sanic.request import Request | ||||||
|  |  | ||||||
|  | from .base import BasePage | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Avoid showing the request in the traceback variable inspectors | ||||||
|  | inspector.blacklist_types += (Request,) | ||||||
|  |  | ||||||
|  | ENDUSER_TEXT = """\ | ||||||
|  | We're sorry, but it looks like something went wrong. Please try refreshing \ | ||||||
|  | the page or navigating back to the homepage. If the issue persists, our \ | ||||||
|  | technical team is working to resolve it as soon as possible. We apologize \ | ||||||
|  | for the inconvenience and appreciate your patience.\ | ||||||
|  | """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ErrorPage(BasePage): | ||||||
|  |     STYLE_APPEND = tracerite.html.style | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         debug: bool, | ||||||
|  |         title: str, | ||||||
|  |         text: str, | ||||||
|  |         request: Request, | ||||||
|  |         exc: Exception, | ||||||
|  |     ) -> None: | ||||||
|  |         super().__init__(debug) | ||||||
|  |         name = request.app.name.replace("_", " ").strip() | ||||||
|  |         if name.islower(): | ||||||
|  |             name = name.title() | ||||||
|  |         self.TITLE = f"Application {name} cannot handle your request" | ||||||
|  |         self.HEADING = E("Application ").strong(name)( | ||||||
|  |             " cannot handle your request" | ||||||
|  |         ) | ||||||
|  |         self.title = title | ||||||
|  |         self.text = text | ||||||
|  |         self.request = request | ||||||
|  |         self.exc = exc | ||||||
|  |         self.details_open = not getattr(exc, "quiet", False) | ||||||
|  |  | ||||||
|  |     def _head(self) -> None: | ||||||
|  |         self.doc._script(tracerite.html.javascript) | ||||||
|  |         super()._head() | ||||||
|  |  | ||||||
|  |     def _body(self) -> None: | ||||||
|  |         debug = self.request.app.debug | ||||||
|  |         route_name = self.request.name or "[route not found]" | ||||||
|  |         with self.doc.main: | ||||||
|  |             self.doc.h1(f"⚠️ {self.title}").p(self.text) | ||||||
|  |             # Show context details if available on the exception | ||||||
|  |             context = getattr(self.exc, "context", None) | ||||||
|  |             if context: | ||||||
|  |                 self._key_value_table( | ||||||
|  |                     "Issue context", "exception-context", context | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |             if not debug: | ||||||
|  |                 with self.doc.div(id="enduser"): | ||||||
|  |                     self.doc.p(ENDUSER_TEXT).p.a("Front Page", href="/") | ||||||
|  |                 return | ||||||
|  |             # Show additional details in debug mode, | ||||||
|  |             # open by default for 500 errors | ||||||
|  |             with self.doc.details(open=self.details_open, class_="smalltext"): | ||||||
|  |                 # Show extra details if available on the exception | ||||||
|  |                 extra = getattr(self.exc, "extra", None) | ||||||
|  |                 if extra: | ||||||
|  |                     self._key_value_table( | ||||||
|  |                         "Issue extra data", "exception-extra", extra | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                 self.doc.summary( | ||||||
|  |                     "Details for developers (Sanic debug mode only)" | ||||||
|  |                 ) | ||||||
|  |                 if self.exc: | ||||||
|  |                     with self.doc.div(class_="exception-wrapper"): | ||||||
|  |                         self.doc.h2(f"Exception in {route_name}:") | ||||||
|  |                         self.doc( | ||||||
|  |                             html_traceback(self.exc, include_js_css=False) | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                 self._key_value_table( | ||||||
|  |                     f"{self.request.method} {self.request.path}", | ||||||
|  |                     "request-headers", | ||||||
|  |                     self.request.headers, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |     def _key_value_table( | ||||||
|  |         self, title: str, table_id: str, data: Mapping[str, Any] | ||||||
|  |     ) -> None: | ||||||
|  |         with self.doc.div(class_="key-value-display"): | ||||||
|  |             self.doc.h2(title) | ||||||
|  |             with self.doc.dl(id=table_id, class_="key-value-table smalltext"): | ||||||
|  |                 for key, value in data.items(): | ||||||
|  |                     # Reading values may cause a new exception, so suppress it | ||||||
|  |                     try: | ||||||
|  |                         value = str(value) | ||||||
|  |                     except Exception: | ||||||
|  |                         value = E.em("Unable to display value") | ||||||
|  |                     self.doc.dt.span(key, class_="nobr key").span(": ").dd( | ||||||
|  |                         value | ||||||
|  |                     ) | ||||||
							
								
								
									
										146
									
								
								sanic/pages/styles/BasePage.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								sanic/pages/styles/BasePage.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | |||||||
|  | /** BasePage **/ | ||||||
|  |  | ||||||
|  | :root { | ||||||
|  |     --sanic: #ff0d68; | ||||||
|  |     --sanic-yellow: #FFE900; | ||||||
|  |     --sanic-background: #efeced; | ||||||
|  |     --sanic-text: #121010; | ||||||
|  |     --sanic-text-lighter: #756169; | ||||||
|  |     --sanic-link: #ff0d68; | ||||||
|  |     --sanic-block-background: #f7f4f6; | ||||||
|  |     --sanic-block-text: #000; | ||||||
|  |     --sanic-block-alt-text: #6b6468; | ||||||
|  |     --sanic-header-background: #272325; | ||||||
|  |     --sanic-header-border: #fff; | ||||||
|  |     --sanic-header-text: #fff; | ||||||
|  |     --sanic-highlight-background: var(--sanic-yellow); | ||||||
|  |     --sanic-highlight-text: var(--sanic-text); | ||||||
|  |     --sanic-tab-background: #f7f4f6; | ||||||
|  |     --sanic-tab-shadow: #f7f6f6; | ||||||
|  |     --sanic-tab-text: #222021; | ||||||
|  |     --sanic-tracerite-var: var(--sanic-text); | ||||||
|  |     --sanic-tracerite-val: #ff0d68; | ||||||
|  |     --sanic-tracerite-type: #6d6a6b; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @media (prefers-color-scheme: dark) { | ||||||
|  |     :root { | ||||||
|  |         --sanic-text: #f7f4f6; | ||||||
|  |         --sanic-background: #121010; | ||||||
|  |         --sanic-block-background: #0f0d0e; | ||||||
|  |         --sanic-block-text: #f7f4f6; | ||||||
|  |         --sanic-header-background: #030203; | ||||||
|  |         --sanic-header-border: #000; | ||||||
|  |         --sanic-highlight-text: var(--sanic-background); | ||||||
|  |         --sanic-tab-background: #292728; | ||||||
|  |         --sanic-tab-shadow: #0f0d0e; | ||||||
|  |         --sanic-tab-text: #aea7ab; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | html { | ||||||
|  |     font: 16px sans-serif; | ||||||
|  |     background: var(--sanic-background); | ||||||
|  |     color: var(--sanic-text); | ||||||
|  |     scrollbar-gutter: stable; | ||||||
|  |     overflow: hidden auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body { | ||||||
|  |     margin: 0; | ||||||
|  |     font-size: 1.25rem; | ||||||
|  |     line-height: 125%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | body>* { | ||||||
|  |     padding: 1rem 2vw; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 1000px) { | ||||||
|  |     body>* { | ||||||
|  |         padding: 0.5rem 1.5vw; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     html { | ||||||
|  |         /* Scale everything by rem of 6px-16px by viewport width */ | ||||||
|  |         font-size: calc(6px + 10 * 100vw / 1000); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | main { | ||||||
|  |     /* Make sure the footer is closer to bottom */ | ||||||
|  |     min-height: 70vh; | ||||||
|  |     /* Generous padding for readability */ | ||||||
|  |     padding: 1rem 2.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .smalltext { | ||||||
|  |     font-size: 1.0rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .container { | ||||||
|  |     min-width: 600px; | ||||||
|  |     max-width: 1600px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | header { | ||||||
|  |     background: var(--sanic-header-background); | ||||||
|  |     color: var(--sanic-header-text); | ||||||
|  |     border-bottom: 1px solid var(--sanic-header-border); | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | footer { | ||||||
|  |     text-align: center; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     margin: 2rem; | ||||||
|  |     line-height: 1.5em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | h1 { | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | a { | ||||||
|  |     text-decoration: none; | ||||||
|  |     color: var(--sanic-link); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | a:hover, | ||||||
|  | a:focus { | ||||||
|  |     text-decoration: underline; | ||||||
|  |     outline: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | span.icon { | ||||||
|  |     margin-right: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #logo-simple { | ||||||
|  |     height: 1.75rem; | ||||||
|  |     padding: 0 0.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @media (prefers-color-scheme: dark) { | ||||||
|  |     #logo-simple path:last-child { | ||||||
|  |         fill: #e1e1e1; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #sanic pre, | ||||||
|  | #sanic code { | ||||||
|  |     font-family: "Fira Code", | ||||||
|  |         "Source Code Pro", | ||||||
|  |         Menlo, | ||||||
|  |         Meslo, | ||||||
|  |         Monaco, | ||||||
|  |         Consolas, | ||||||
|  |         Lucida Console, | ||||||
|  |         monospace; | ||||||
|  |     font-size: 0.8rem; | ||||||
|  | } | ||||||
							
								
								
									
										63
									
								
								sanic/pages/styles/DirectoryPage.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								sanic/pages/styles/DirectoryPage.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | /** DirectoryPage **/ | ||||||
|  | #breadcrumbs>a:hover { | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #breadcrumbs>a .dir { | ||||||
|  |     padding: 0 0.25em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #breadcrumbs>a:first-child:hover::before, | ||||||
|  | #breadcrumbs>a .dir:hover { | ||||||
|  |     text-decoration: underline; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #breadcrumbs>a:first-child::before { | ||||||
|  |     content: "🏠"; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #breadcrumbs>a:last-child { | ||||||
|  |     color: #ff0d68; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | main a { | ||||||
|  |     color: inherit; | ||||||
|  |     font-weight: bold; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex { | ||||||
|  |     width: 100%; | ||||||
|  |     font-family: monospace; | ||||||
|  |     font-size: 1.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex tr { | ||||||
|  |     display: flex; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex tr:hover { | ||||||
|  |     background-color: #ddd; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex td { | ||||||
|  |     margin: 0 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex td:first-child { | ||||||
|  |     flex: 1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex td:nth-child(2) { | ||||||
|  |     text-align: right; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | table.autoindex td:last-child { | ||||||
|  |     text-align: right; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @media (prefers-color-scheme: dark) { | ||||||
|  |     table.autoindex tr:hover { | ||||||
|  |         background-color: #222; | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										108
									
								
								sanic/pages/styles/ErrorPage.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								sanic/pages/styles/ErrorPage.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | |||||||
|  | /** ErrorPage **/ | ||||||
|  | #enduser { | ||||||
|  |     max-width: 30em; | ||||||
|  |     margin: 5em auto 5em auto; | ||||||
|  |     text-align: justify; | ||||||
|  |     /*text-justify: both;*/ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #enduser a { | ||||||
|  |     color: var(--sanic-blue); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #enduser p:last-child { | ||||||
|  |     text-align: right; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | summary { | ||||||
|  |     margin-top: 3em; | ||||||
|  |     color: var(--sanic-text-lighter); | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tracerite { | ||||||
|  |     --tracerite-var: var(--sanic-tracerite-var); | ||||||
|  |     --tracerite-val: var(--sanic-tracerite-val); | ||||||
|  |     --tracerite-type: var(--sanic-tracerite-type); | ||||||
|  |     --tracerite-exception: var(--sanic); | ||||||
|  |     --tracerite-highlight: var(--sanic-yellow); | ||||||
|  |     --tracerite-tab: var(--sanic-tab-background); | ||||||
|  |     --tracerite-tab-text: var(--sanic-tab-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tracerite>h3 { | ||||||
|  |     margin: 0.5rem 0 !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #sanic .tracerite .traceback-labels button { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     line-height: 120%; | ||||||
|  |     background: var(--tracerite-tab); | ||||||
|  |     color: var(--tracerite-tab-text); | ||||||
|  |     transition: 0.3s; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tracerite .traceback-labels { | ||||||
|  |     padding-top: 5px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tracerite .traceback-labels button:hover { | ||||||
|  |     filter: contrast(150%) brightness(120%) drop-shadow(0 -0 2px var(--sanic-tab-shadow)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #sanic .tracerite .tracerite-tooltip::before { | ||||||
|  |     bottom: 1.75em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #sanic .tracerite .traceback-details mark span { | ||||||
|  |     background: var(--sanic-highlight-background); | ||||||
|  |     color: var(--sanic-highlight-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | header { | ||||||
|  |     background: var(--sanic-header-background); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | h2 { | ||||||
|  |     font-size: 1.3rem; | ||||||
|  |     color: var(--sanic-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .key-value-display, | ||||||
|  | .exception-wrapper { | ||||||
|  |     padding: 0.5rem; | ||||||
|  |     margin-top: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .key-value-display { | ||||||
|  |     background-color: var(--sanic-block-background); | ||||||
|  |     color: var(--sanic-block-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .key-value-display h2 { | ||||||
|  |     margin-bottom: 0.2em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | dl.key-value-table { | ||||||
|  |     width: 100%; | ||||||
|  |     margin: 0; | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: 1fr 5fr; | ||||||
|  |     grid-gap: .3em; | ||||||
|  |     white-space: pre-wrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | dl.key-value-table * { | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | dl.key-value-table dt { | ||||||
|  |     color: var(--sanic-block-alt-text); | ||||||
|  |     word-break: break-word; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | dl.key-value-table dd { | ||||||
|  |     /* Better breaking for cookies header and such */ | ||||||
|  |     word-break: break-all; | ||||||
|  | } | ||||||
							
								
								
									
										1128
									
								
								sanic/request.py
									
									
									
									
									
								
							
							
						
						
									
										1128
									
								
								sanic/request.py
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -39,13 +39,13 @@ class Router(BaseRouter): | |||||||
|                 extra={"host": host} if host else None, |                 extra={"host": host} if host else None, | ||||||
|             ) |             ) | ||||||
|         except RoutingNotFound as e: |         except RoutingNotFound as e: | ||||||
|             raise NotFound("Requested URL {} not found".format(e.path)) |             raise NotFound(f"Requested URL {e.path} not found") from None | ||||||
|         except NoMethod as e: |         except NoMethod as e: | ||||||
|             raise MethodNotAllowed( |             raise MethodNotAllowed( | ||||||
|                 "Method {} not allowed for URL {}".format(method, path), |                 f"Method {method} not allowed for URL {path}", | ||||||
|                 method=method, |                 method=method, | ||||||
|                 allowed_methods=e.allowed_methods, |                 allowed_methods=e.allowed_methods, | ||||||
|             ) |             ) from None | ||||||
|  |  | ||||||
|     @lru_cache(maxsize=ROUTER_CACHE_SIZE) |     @lru_cache(maxsize=ROUTER_CACHE_SIZE) | ||||||
|     def get(  # type: ignore |     def get(  # type: ignore | ||||||
| @@ -61,6 +61,7 @@ class Router(BaseRouter): | |||||||
|             correct response |             correct response | ||||||
|         :rtype: Tuple[ Route, RouteHandler, Dict[str, Any]] |         :rtype: Tuple[ Route, RouteHandler, Dict[str, Any]] | ||||||
|         """ |         """ | ||||||
|  |         __tracebackhide__ = True | ||||||
|         return self._get(path, method, host) |         return self._get(path, method, host) | ||||||
|  |  | ||||||
|     def add(  # type: ignore |     def add(  # type: ignore | ||||||
|   | |||||||
| @@ -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}. | ||||||
|   | |||||||
| @@ -1,2 +1,4 @@ | |||||||
| [flake8] | [flake8] | ||||||
| ignore = E203, W503 | ignore = E203, W503 | ||||||
|  | per-file-ignores = | ||||||
|  |     sanic/app.py:E402 | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								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,8 @@ 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", | ||||||
|  |     "tracerite>=1.0.0", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| 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) | ||||||
|  |  | ||||||
| @@ -352,6 +352,17 @@ 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( | ||||||
|     send, receive, message_stack |     send, receive, message_stack | ||||||
|   | |||||||
| @@ -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("/") | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
|  | import logging | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from sanic import Sanic | from sanic import Sanic | ||||||
| from sanic.config import Config | from sanic.config import Config | ||||||
| from sanic.errorpages import HTMLRenderer, exception_response | from sanic.errorpages import TextRenderer, exception_response, guess_mime | ||||||
| from sanic.exceptions import NotFound, SanicException | from sanic.exceptions import NotFound, SanicException | ||||||
| from sanic.handlers import ErrorHandler | from sanic.handlers import ErrorHandler | ||||||
| from sanic.request import Request | from sanic.request import Request | ||||||
| from sanic.response import HTTPResponse, html, json, text | from sanic.response import HTTPResponse, empty, html, json, text | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| @@ -17,6 +19,44 @@ def app(): | |||||||
|     def err(request): |     def err(request): | ||||||
|         raise Exception("something went wrong") |         raise Exception("something went wrong") | ||||||
|  |  | ||||||
|  |     @app.get("/forced_json/<fail>", error_format="json") | ||||||
|  |     def manual_fail(request, fail): | ||||||
|  |         if fail == "fail": | ||||||
|  |             raise Exception | ||||||
|  |         return html("")  # Should be ignored | ||||||
|  |  | ||||||
|  |     @app.get("/empty/<fail>") | ||||||
|  |     def empty_fail(request, fail): | ||||||
|  |         if fail == "fail": | ||||||
|  |             raise Exception | ||||||
|  |         return empty() | ||||||
|  |  | ||||||
|  |     @app.get("/json/<fail>") | ||||||
|  |     def json_fail(request, fail): | ||||||
|  |         if fail == "fail": | ||||||
|  |             raise Exception | ||||||
|  |         # After 23.3 route format should become json, older versions think it | ||||||
|  |         # is mixed due to empty mapping to html, and don't find any format. | ||||||
|  |         return json({"foo": "bar"}) if fail == "json" else empty() | ||||||
|  |  | ||||||
|  |     @app.get("/html/<fail>") | ||||||
|  |     def html_fail(request, fail): | ||||||
|  |         if fail == "fail": | ||||||
|  |             raise Exception | ||||||
|  |         return html("<h1>foo</h1>") | ||||||
|  |  | ||||||
|  |     @app.get("/text/<fail>") | ||||||
|  |     def text_fail(request, fail): | ||||||
|  |         if fail == "fail": | ||||||
|  |             raise Exception | ||||||
|  |         return text("foo") | ||||||
|  |  | ||||||
|  |     @app.get("/mixed/<param>") | ||||||
|  |     def mixed_fail(request, param): | ||||||
|  |         if param not in ("json", "html"): | ||||||
|  |             raise Exception | ||||||
|  |         return json({}) if param == "json" else html("") | ||||||
|  |  | ||||||
|     return app |     return app | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -28,14 +68,14 @@ def fake_request(app): | |||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     "fallback,content_type, exception, status", |     "fallback,content_type, exception, status", | ||||||
|     ( |     ( | ||||||
|         (None, "text/html; charset=utf-8", Exception, 500), |         (None, "text/plain; charset=utf-8", Exception, 500), | ||||||
|         ("html", "text/html; charset=utf-8", Exception, 500), |         ("html", "text/html; charset=utf-8", Exception, 500), | ||||||
|         ("auto", "text/html; charset=utf-8", Exception, 500), |         ("auto", "text/plain; charset=utf-8", Exception, 500), | ||||||
|         ("text", "text/plain; charset=utf-8", Exception, 500), |         ("text", "text/plain; charset=utf-8", Exception, 500), | ||||||
|         ("json", "application/json", Exception, 500), |         ("json", "application/json", Exception, 500), | ||||||
|         (None, "text/html; charset=utf-8", NotFound, 404), |         (None, "text/plain; charset=utf-8", NotFound, 404), | ||||||
|         ("html", "text/html; charset=utf-8", NotFound, 404), |         ("html", "text/html; charset=utf-8", NotFound, 404), | ||||||
|         ("auto", "text/html; charset=utf-8", NotFound, 404), |         ("auto", "text/plain; charset=utf-8", NotFound, 404), | ||||||
|         ("text", "text/plain; charset=utf-8", NotFound, 404), |         ("text", "text/plain; charset=utf-8", NotFound, 404), | ||||||
|         ("json", "application/json", NotFound, 404), |         ("json", "application/json", NotFound, 404), | ||||||
|     ), |     ), | ||||||
| @@ -43,6 +83,10 @@ def fake_request(app): | |||||||
| def test_should_return_html_valid_setting( | def test_should_return_html_valid_setting( | ||||||
|     fake_request, fallback, content_type, exception, status |     fake_request, fallback, content_type, exception, status | ||||||
| ): | ): | ||||||
|  |     # Note: if fallback is None or "auto", prior to PR #2668 base was returned | ||||||
|  |     # and after that a text response is given because it matches */*. Changed | ||||||
|  |     # base to TextRenderer in this test, like it is in Sanic itself, so the | ||||||
|  |     # test passes with either version but still covers everything that it did. | ||||||
|     if fallback: |     if fallback: | ||||||
|         fake_request.app.config.FALLBACK_ERROR_FORMAT = fallback |         fake_request.app.config.FALLBACK_ERROR_FORMAT = fallback | ||||||
|  |  | ||||||
| @@ -53,7 +97,7 @@ def test_should_return_html_valid_setting( | |||||||
|             fake_request, |             fake_request, | ||||||
|             e, |             e, | ||||||
|             True, |             True, | ||||||
|             base=HTMLRenderer, |             base=TextRenderer, | ||||||
|             fallback=fake_request.app.config.FALLBACK_ERROR_FORMAT, |             fallback=fake_request.app.config.FALLBACK_ERROR_FORMAT, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -259,15 +303,16 @@ def test_fallback_with_content_type_mismatch_accept(app): | |||||||
|     "accept,content_type,expected", |     "accept,content_type,expected", | ||||||
|     ( |     ( | ||||||
|         (None, None, "text/plain; charset=utf-8"), |         (None, None, "text/plain; charset=utf-8"), | ||||||
|         ("foo/bar", None, "text/html; charset=utf-8"), |         ("foo/bar", None, "text/plain; charset=utf-8"), | ||||||
|         ("application/json", None, "application/json"), |         ("application/json", None, "application/json"), | ||||||
|         ("application/json,text/plain", None, "application/json"), |         ("application/json,text/plain", None, "application/json"), | ||||||
|         ("text/plain,application/json", None, "application/json"), |         ("text/plain,application/json", None, "application/json"), | ||||||
|         ("text/plain,foo/bar", None, "text/plain; charset=utf-8"), |         ("text/plain,foo/bar", None, "text/plain; charset=utf-8"), | ||||||
|         # Following test is valid after v22.3 |         ("text/plain,text/html", None, "text/plain; charset=utf-8"), | ||||||
|         # ("text/plain,text/html", None, "text/plain; charset=utf-8"), |         ("*/*", "foo/bar", "text/plain; charset=utf-8"), | ||||||
|         ("*/*", "foo/bar", "text/html; charset=utf-8"), |  | ||||||
|         ("*/*", "application/json", "application/json"), |         ("*/*", "application/json", "application/json"), | ||||||
|  |         # App wants text/plain but accept has equal entries for it | ||||||
|  |         ("text/*,*/plain", None, "text/plain; charset=utf-8"), | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
| def test_combinations_for_auto(fake_request, accept, content_type, expected): | def test_combinations_for_auto(fake_request, accept, content_type, expected): | ||||||
| @@ -286,7 +331,7 @@ def test_combinations_for_auto(fake_request, accept, content_type, expected): | |||||||
|             fake_request, |             fake_request, | ||||||
|             e, |             e, | ||||||
|             True, |             True, | ||||||
|             base=HTMLRenderer, |             base=TextRenderer, | ||||||
|             fallback="auto", |             fallback="auto", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -376,3 +421,109 @@ def test_config_fallback_bad_value(app): | |||||||
|     message = "Unknown format: fake" |     message = "Unknown format: fake" | ||||||
|     with pytest.raises(SanicException, match=message): |     with pytest.raises(SanicException, match=message): | ||||||
|         app.config.FALLBACK_ERROR_FORMAT = "fake" |         app.config.FALLBACK_ERROR_FORMAT = "fake" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "route_format,fallback,accept,expected", | ||||||
|  |     ( | ||||||
|  |         ( | ||||||
|  |             "json", | ||||||
|  |             "html", | ||||||
|  |             "*/*", | ||||||
|  |             "The client accepts */*, using 'json' from fakeroute", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "json", | ||||||
|  |             "auto", | ||||||
|  |             "text/html,*/*;q=0.8", | ||||||
|  |             "The client accepts text/html, using 'html' from any", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "json", | ||||||
|  |             "json", | ||||||
|  |             "text/html,*/*;q=0.8", | ||||||
|  |             "The client accepts */*;q=0.8, using 'json' from fakeroute", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "html", | ||||||
|  |             "text/*,*/plain", | ||||||
|  |             "The client accepts text/*, using 'html' from FALLBACK_ERROR_FORMAT", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "json", | ||||||
|  |             "text/*,*/*", | ||||||
|  |             "The client accepts */*, using 'json' from FALLBACK_ERROR_FORMAT", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "auto", | ||||||
|  |             "*/*,application/json;q=0.5", | ||||||
|  |             "The client accepts */*, using 'json' from request.accept", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "auto", | ||||||
|  |             "*/*", | ||||||
|  |             "The client accepts */*, using 'json' from content-type", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "auto", | ||||||
|  |             "text/html,text/plain", | ||||||
|  |             "The client accepts text/plain, using 'text' from any", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "auto", | ||||||
|  |             "text/html,text/plain;q=0.9", | ||||||
|  |             "The client accepts text/html, using 'html' from any", | ||||||
|  |         ), | ||||||
|  |         ( | ||||||
|  |             "html", | ||||||
|  |             "json", | ||||||
|  |             "application/xml", | ||||||
|  |             "No format found, the client accepts [application/xml]", | ||||||
|  |         ), | ||||||
|  |         ("", "auto", "*/*", "The client accepts */*, using 'text' from any"), | ||||||
|  |         ("", "", "*/*", "No format found, the client accepts [*/*]"), | ||||||
|  |         # DEPRECATED: remove in 24.3 | ||||||
|  |         ( | ||||||
|  |             "", | ||||||
|  |             "auto", | ||||||
|  |             "*/*", | ||||||
|  |             "The client accepts */*, using 'json' from request.json", | ||||||
|  |         ), | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  | def test_guess_mime_logging( | ||||||
|  |     caplog, fake_request, route_format, fallback, accept, expected | ||||||
|  | ): | ||||||
|  |     class FakeObject: | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |     fake_request.route = FakeObject() | ||||||
|  |     fake_request.route.name = "fakeroute" | ||||||
|  |     fake_request.route.extra = FakeObject() | ||||||
|  |     fake_request.route.extra.error_format = route_format | ||||||
|  |     if accept is None: | ||||||
|  |         del fake_request.headers["accept"] | ||||||
|  |     else: | ||||||
|  |         fake_request.headers["accept"] = accept | ||||||
|  |  | ||||||
|  |     if "content-type" in expected: | ||||||
|  |         fake_request.headers["content-type"] = "application/json" | ||||||
|  |  | ||||||
|  |     # Fake JSON content (DEPRECATED: remove in 24.3) | ||||||
|  |     if "request.json" in expected: | ||||||
|  |         fake_request.parsed_json = {"foo": "bar"} | ||||||
|  |  | ||||||
|  |     with caplog.at_level(logging.DEBUG, logger="sanic.root"): | ||||||
|  |         guess_mime(fake_request, fallback) | ||||||
|  |  | ||||||
|  |     (logmsg,) = [ | ||||||
|  |         r.message for r in caplog.records if r.funcName == "guess_mime" | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |     assert logmsg == expected | ||||||
|   | |||||||
| @@ -23,11 +23,11 @@ from sanic.exceptions import ( | |||||||
| from sanic.response import text | from sanic.response import text | ||||||
|  |  | ||||||
|  |  | ||||||
| def dl_to_dict(soup, css_class): | def dl_to_dict(soup, dl_id): | ||||||
|     keys, values = [], [] |     keys, values = [], [] | ||||||
|     for dl in soup.find_all("dl", {"class": css_class}): |     for dl in soup.find_all("dl", {"id": dl_id}): | ||||||
|         for dt in dl.find_all("dt"): |         for dt in dl.find_all("dt"): | ||||||
|             keys.append(dt.text.strip()) |             keys.append(dt.text.split(":", 1)[0]) | ||||||
|         for dd in dl.find_all("dd"): |         for dd in dl.find_all("dd"): | ||||||
|             values.append(dd.text.strip()) |             values.append(dd.text.strip()) | ||||||
|     return dict(zip(keys, values)) |     return dict(zip(keys, values)) | ||||||
| @@ -194,10 +194,7 @@ def test_handled_unhandled_exception(exception_app): | |||||||
|     assert "Internal Server Error" in soup.h1.text |     assert "Internal Server Error" in soup.h1.text | ||||||
|  |  | ||||||
|     message = " ".join(soup.p.text.split()) |     message = " ".join(soup.p.text.split()) | ||||||
|     assert message == ( |     assert "The application encountered an unexpected error" in message | ||||||
|         "The server encountered an internal error and " |  | ||||||
|         "cannot complete your request." |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_exception_in_exception_handler(exception_app): | def test_exception_in_exception_handler(exception_app): | ||||||
| @@ -299,7 +296,7 @@ def test_contextual_exception_context(debug): | |||||||
|  |  | ||||||
|     _, response = app.test_client.post("/coffee/html", debug=debug) |     _, response = app.test_client.post("/coffee/html", debug=debug) | ||||||
|     soup = BeautifulSoup(response.body, "html.parser") |     soup = BeautifulSoup(response.body, "html.parser") | ||||||
|     dl = dl_to_dict(soup, "context") |     dl = dl_to_dict(soup, "exception-context") | ||||||
|     assert response.status == 418 |     assert response.status == 418 | ||||||
|     assert "Sorry, I cannot brew coffee" in soup.find("p").text |     assert "Sorry, I cannot brew coffee" in soup.find("p").text | ||||||
|     assert dl == {"foo": "bar"} |     assert dl == {"foo": "bar"} | ||||||
| @@ -340,7 +337,7 @@ def test_contextual_exception_extra(debug): | |||||||
|  |  | ||||||
|     _, response = app.test_client.post("/coffee/html", debug=debug) |     _, response = app.test_client.post("/coffee/html", debug=debug) | ||||||
|     soup = BeautifulSoup(response.body, "html.parser") |     soup = BeautifulSoup(response.body, "html.parser") | ||||||
|     dl = dl_to_dict(soup, "extra") |     dl = dl_to_dict(soup, "exception-extra") | ||||||
|     assert response.status == 418 |     assert response.status == 418 | ||||||
|     assert "Found bar" in soup.find("p").text |     assert "Found bar" in soup.find("p").text | ||||||
|     if debug: |     if debug: | ||||||
|   | |||||||
| @@ -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) | ||||||
| @@ -124,10 +123,10 @@ def test_html_traceback_output_in_debug_mode(exception_handler_app: Sanic): | |||||||
|     assert "handler_4" in html |     assert "handler_4" in html | ||||||
|     assert "foo = bar" in html |     assert "foo = bar" in html | ||||||
|  |  | ||||||
|     summary_text = " ".join(soup.select(".summary")[0].text.split()) |     summary_text = soup.select("h3")[0].text | ||||||
|     assert ( |     assert "NameError: name 'bar' is not defined" == summary_text | ||||||
|         "NameError: name 'bar' is not defined while handling path /4" |     request_text = soup.select("h2")[-1].text | ||||||
|     ) == summary_text |     assert "GET /4" == request_text | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_inherited_exception_handler(exception_handler_app: Sanic): | def test_inherited_exception_handler(exception_handler_app: Sanic): | ||||||
| @@ -147,11 +146,10 @@ def test_chained_exception_handler(exception_handler_app: Sanic): | |||||||
|     assert "handler_6" in html |     assert "handler_6" in html | ||||||
|     assert "foo = 1 / arg" in html |     assert "foo = 1 / arg" in html | ||||||
|     assert "ValueError" in html |     assert "ValueError" in html | ||||||
|  |     assert "GET /6" in html | ||||||
|  |  | ||||||
|     summary_text = " ".join(soup.select(".summary")[0].text.split()) |     summary_text = soup.select("h3")[0].text | ||||||
|     assert ( |     assert "ZeroDivisionError: division by zero" == summary_text | ||||||
|         "ZeroDivisionError: division by zero while handling path /6/0" |  | ||||||
|     ) == summary_text |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_exception_handler_lookup(exception_handler_app: Sanic): | def test_exception_handler_lookup(exception_handler_app: Sanic): | ||||||
| @@ -214,7 +212,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") | ||||||
|   | |||||||
| @@ -2,12 +2,16 @@ from unittest.mock import Mock | |||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from sanic import headers, text | from sanic import Sanic, headers, json, text | ||||||
| from sanic.exceptions import InvalidHeader, PayloadTooLarge | from sanic.exceptions import InvalidHeader, PayloadTooLarge | ||||||
| from sanic.http import Http | from sanic.http import Http | ||||||
| from sanic.request import Request | from sanic.request import Request | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def make_request(headers) -> Request: | ||||||
|  |     return Request(b"/", headers, "1.1", "GET", None, None) | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def raised_ceiling(): | def raised_ceiling(): | ||||||
|     Http.HEADER_CEILING = 32_768 |     Http.HEADER_CEILING = 32_768 | ||||||
| @@ -45,29 +49,17 @@ def raised_ceiling(): | |||||||
|             ("attachment", {"filename": "strange;name", "size": "123"}), |             ("attachment", {"filename": "strange;name", "size": "123"}), | ||||||
|         ), |         ), | ||||||
|         ( |         ( | ||||||
|             'form-data; name="files"; filename="fo\\"o;bar\\"', |             'form-data; name="foo"; value="%22\\%0D%0A"', | ||||||
|             ("form-data", {"name": "files", "filename": 'fo"o;bar\\'}) |             ("form-data", {"name": "foo", "value": '"\\\n'}), | ||||||
|             # cgi.parse_header: |  | ||||||
|             # ('form-data', {'name': 'files', 'filename': 'fo"o;bar\\'}) |  | ||||||
|             # werkzeug.parse_options_header: |  | ||||||
|             # ('form-data', {'name': 'files', 'filename': '"fo\\"o', 'bar\\"': None}) |  | ||||||
|         ), |         ), | ||||||
|         # <input type=file name="foo";bar\"> with Unicode filename! |         # <input type=file name="foo";bar\"> with Unicode filename! | ||||||
|         ( |         ( | ||||||
|             # Chrome: |             # Chrome, Firefox: | ||||||
|             # Content-Disposition: form-data; name="foo%22;bar\"; filename="😀" |             # Content-Disposition: form-data; name="foo%22;bar\"; filename="😀" | ||||||
|             'form-data; name="foo%22;bar\\"; filename="😀"', |             'form-data; name="foo%22;bar\\"; filename="😀"', | ||||||
|             ("form-data", {"name": 'foo";bar\\', "filename": "😀"}) |             ("form-data", {"name": 'foo";bar\\', "filename": "😀"}) | ||||||
|             # cgi: ('form-data', {'name': 'foo%22;bar"; filename="😀'}) |             # cgi: ('form-data', {'name': 'foo%22;bar"; filename="😀'}) | ||||||
|             # werkzeug: ('form-data', {'name': 'foo%22;bar"; filename='}) |             # werkzeug (pre 2.3.0): ('form-data', {'name': 'foo%22;bar"; filename='}) | ||||||
|         ), |  | ||||||
|         ( |  | ||||||
|             # Firefox: |  | ||||||
|             # Content-Disposition: form-data; name="foo\";bar\"; filename="😀" |  | ||||||
|             'form-data; name="foo\\";bar\\"; filename="😀"', |  | ||||||
|             ("form-data", {"name": 'foo";bar\\', "filename": "😀"}) |  | ||||||
|             # cgi: ('form-data', {'name': 'foo";bar"; filename="😀'}) |  | ||||||
|             # werkzeug: ('form-data', {'name': 'foo";bar"; filename='}) |  | ||||||
|         ), |         ), | ||||||
|     ], |     ], | ||||||
| ) | ) | ||||||
| @@ -187,27 +179,24 @@ def test_request_line(app): | |||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     "raw", |     "raw,expected_subtype", | ||||||
|     ( |     ( | ||||||
|         "show/first, show/second", |         ("show/first, show/second", "first"), | ||||||
|         "show/*, show/first", |         ("show/*, show/first", "first"), | ||||||
|         "*/*, show/first", |         ("*/*, show/first", "first"), | ||||||
|         "*/*, show/*", |         ("*/*, show/*", "*"), | ||||||
|         "other/*; q=0.1, show/*; q=0.2", |         ("other/*; q=0.1, show/*; q=0.2", "*"), | ||||||
|         "show/first; q=0.5, show/second; q=0.5", |         ("show/first; q=0.5, show/second; q=0.5", "first"), | ||||||
|         "show/first; foo=bar, show/second; foo=bar", |         ("show/first; foo=bar, show/second; foo=bar", "first"), | ||||||
|         "show/second, show/first; foo=bar", |         ("show/second, show/first; foo=bar", "first"), | ||||||
|         "show/second; q=0.5, show/first; foo=bar; q=0.5", |         ("show/second; q=0.5, show/first; foo=bar; q=0.5", "first"), | ||||||
|         "show/second; q=0.5, show/first; q=1.0", |         ("show/second; q=0.5, show/first; q=1.0", "first"), | ||||||
|         "show/first, show/second; q=1.0", |         ("show/first, show/second; q=1.0", "second"), | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
| def test_parse_accept_ordered_okay(raw): | def test_parse_accept_ordered_okay(raw, expected_subtype): | ||||||
|     ordered = headers.parse_accept(raw) |     ordered = headers.parse_accept(raw) | ||||||
|     expected_subtype = ( |     assert ordered[0].type == "show" | ||||||
|         "*" if all(q.subtype.is_wildcard for q in ordered) else "first" |  | ||||||
|     ) |  | ||||||
|     assert ordered[0].type_ == "show" |  | ||||||
|     assert ordered[0].subtype == expected_subtype |     assert ordered[0].subtype == expected_subtype | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -217,6 +206,7 @@ def test_parse_accept_ordered_okay(raw): | |||||||
|         "missing", |         "missing", | ||||||
|         "missing/", |         "missing/", | ||||||
|         "/missing", |         "/missing", | ||||||
|  |         "/", | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
| def test_bad_accept(raw): | def test_bad_accept(raw): | ||||||
| @@ -225,128 +215,83 @@ def test_bad_accept(raw): | |||||||
|  |  | ||||||
|  |  | ||||||
| def test_empty_accept(): | def test_empty_accept(): | ||||||
|     assert headers.parse_accept("") == [] |     a = headers.parse_accept("") | ||||||
|  |     assert a == [] | ||||||
|  |     assert not a.match("*/*") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_wildcard_accept_set_ok(): | def test_wildcard_accept_set_ok(): | ||||||
|     accept = headers.parse_accept("*/*")[0] |     accept = headers.parse_accept("*/*")[0] | ||||||
|     assert accept.type_.is_wildcard |     assert accept.type == "*" | ||||||
|     assert accept.subtype.is_wildcard |     assert accept.subtype == "*" | ||||||
|  |     assert accept.has_wildcard | ||||||
|  |  | ||||||
|  |     accept = headers.parse_accept("foo/*")[0] | ||||||
|  |     assert accept.type == "foo" | ||||||
|  |     assert accept.subtype == "*" | ||||||
|  |     assert accept.has_wildcard | ||||||
|  |  | ||||||
|     accept = headers.parse_accept("foo/bar")[0] |     accept = headers.parse_accept("foo/bar")[0] | ||||||
|     assert not accept.type_.is_wildcard |     assert accept.type == "foo" | ||||||
|     assert not accept.subtype.is_wildcard |     assert accept.subtype == "bar" | ||||||
|  |     assert not accept.has_wildcard | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_accept_parsed_against_str(): | def test_accept_parsed_against_str(): | ||||||
|     accept = headers.Accept.parse("foo/bar") |     accept = headers.Matched.parse("foo/bar") | ||||||
|     assert accept > "foo/bar; q=0.1" |     assert accept == "foo/bar; q=0.1" | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_media_type_equality(): |  | ||||||
|     assert headers.MediaType("foo") == headers.MediaType("foo") == "foo" |  | ||||||
|     assert headers.MediaType("foo") == headers.MediaType("*") == "*" |  | ||||||
|     assert headers.MediaType("foo") != headers.MediaType("bar") |  | ||||||
|     assert headers.MediaType("foo") != "bar" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_media_type_matching(): | def test_media_type_matching(): | ||||||
|     assert headers.MediaType("foo").match(headers.MediaType("foo")) |     assert headers.MediaType("foo", "bar").match( | ||||||
|     assert headers.MediaType("foo").match("foo") |         headers.MediaType("foo", "bar") | ||||||
|  |     ) | ||||||
|     assert not headers.MediaType("foo").match(headers.MediaType("*")) |     assert headers.MediaType("foo", "bar").match("foo/bar") | ||||||
|     assert not headers.MediaType("foo").match("*") |  | ||||||
|  |  | ||||||
|     assert not headers.MediaType("foo").match(headers.MediaType("bar")) |  | ||||||
|     assert not headers.MediaType("foo").match("bar") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
|     "value,other,outcome,allow_type,allow_subtype", |     "value,other,outcome", | ||||||
|     ( |     ( | ||||||
|         # ALLOW BOTH |         # ALLOW BOTH | ||||||
|         ("foo/bar", "foo/bar", True, True, True), |         ("foo/bar", "foo/bar", True), | ||||||
|         ("foo/bar", headers.Accept.parse("foo/bar"), True, True, True), |         ("foo/bar", headers.Matched.parse("foo/bar"), True), | ||||||
|         ("foo/bar", "foo/*", True, True, True), |         ("foo/bar", "foo/*", True), | ||||||
|         ("foo/bar", headers.Accept.parse("foo/*"), True, True, True), |         ("foo/bar", headers.Matched.parse("foo/*"), True), | ||||||
|         ("foo/bar", "*/*", True, True, True), |         ("foo/bar", "*/*", True), | ||||||
|         ("foo/bar", headers.Accept.parse("*/*"), True, True, True), |         ("foo/bar", headers.Matched.parse("*/*"), True), | ||||||
|         ("foo/*", "foo/bar", True, True, True), |         ("foo/*", "foo/bar", True), | ||||||
|         ("foo/*", headers.Accept.parse("foo/bar"), True, True, True), |         ("foo/*", headers.Matched.parse("foo/bar"), True), | ||||||
|         ("foo/*", "foo/*", True, True, True), |         ("foo/*", "foo/*", True), | ||||||
|         ("foo/*", headers.Accept.parse("foo/*"), True, True, True), |         ("foo/*", headers.Matched.parse("foo/*"), True), | ||||||
|         ("foo/*", "*/*", True, True, True), |         ("foo/*", "*/*", True), | ||||||
|         ("foo/*", headers.Accept.parse("*/*"), True, True, True), |         ("foo/*", headers.Matched.parse("*/*"), True), | ||||||
|         ("*/*", "foo/bar", True, True, True), |         ("*/*", "foo/bar", True), | ||||||
|         ("*/*", headers.Accept.parse("foo/bar"), True, True, True), |         ("*/*", headers.Matched.parse("foo/bar"), True), | ||||||
|         ("*/*", "foo/*", True, True, True), |         ("*/*", "foo/*", True), | ||||||
|         ("*/*", headers.Accept.parse("foo/*"), True, True, True), |         ("*/*", headers.Matched.parse("foo/*"), True), | ||||||
|         ("*/*", "*/*", True, True, True), |         ("*/*", "*/*", True), | ||||||
|         ("*/*", headers.Accept.parse("*/*"), True, True, True), |         ("*/*", headers.Matched.parse("*/*"), True), | ||||||
|         # ALLOW TYPE |  | ||||||
|         ("foo/bar", "foo/bar", True, True, False), |  | ||||||
|         ("foo/bar", headers.Accept.parse("foo/bar"), True, True, False), |  | ||||||
|         ("foo/bar", "foo/*", False, True, False), |  | ||||||
|         ("foo/bar", headers.Accept.parse("foo/*"), False, True, False), |  | ||||||
|         ("foo/bar", "*/*", False, True, False), |  | ||||||
|         ("foo/bar", headers.Accept.parse("*/*"), False, True, False), |  | ||||||
|         ("foo/*", "foo/bar", False, True, False), |  | ||||||
|         ("foo/*", headers.Accept.parse("foo/bar"), False, True, False), |  | ||||||
|         ("foo/*", "foo/*", False, True, False), |  | ||||||
|         ("foo/*", headers.Accept.parse("foo/*"), False, True, False), |  | ||||||
|         ("foo/*", "*/*", False, True, False), |  | ||||||
|         ("foo/*", headers.Accept.parse("*/*"), False, True, False), |  | ||||||
|         ("*/*", "foo/bar", False, True, False), |  | ||||||
|         ("*/*", headers.Accept.parse("foo/bar"), False, True, False), |  | ||||||
|         ("*/*", "foo/*", False, True, False), |  | ||||||
|         ("*/*", headers.Accept.parse("foo/*"), False, True, False), |  | ||||||
|         ("*/*", "*/*", False, True, False), |  | ||||||
|         ("*/*", headers.Accept.parse("*/*"), False, True, False), |  | ||||||
|         # ALLOW SUBTYPE |  | ||||||
|         ("foo/bar", "foo/bar", True, False, True), |  | ||||||
|         ("foo/bar", headers.Accept.parse("foo/bar"), True, False, True), |  | ||||||
|         ("foo/bar", "foo/*", True, False, True), |  | ||||||
|         ("foo/bar", headers.Accept.parse("foo/*"), True, False, True), |  | ||||||
|         ("foo/bar", "*/*", False, False, True), |  | ||||||
|         ("foo/bar", headers.Accept.parse("*/*"), False, False, True), |  | ||||||
|         ("foo/*", "foo/bar", True, False, True), |  | ||||||
|         ("foo/*", headers.Accept.parse("foo/bar"), True, False, True), |  | ||||||
|         ("foo/*", "foo/*", True, False, True), |  | ||||||
|         ("foo/*", headers.Accept.parse("foo/*"), True, False, True), |  | ||||||
|         ("foo/*", "*/*", False, False, True), |  | ||||||
|         ("foo/*", headers.Accept.parse("*/*"), False, False, True), |  | ||||||
|         ("*/*", "foo/bar", False, False, True), |  | ||||||
|         ("*/*", headers.Accept.parse("foo/bar"), False, False, True), |  | ||||||
|         ("*/*", "foo/*", False, False, True), |  | ||||||
|         ("*/*", headers.Accept.parse("foo/*"), False, False, True), |  | ||||||
|         ("*/*", "*/*", False, False, True), |  | ||||||
|         ("*/*", headers.Accept.parse("*/*"), False, False, True), |  | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
| def test_accept_matching(value, other, outcome, allow_type, allow_subtype): | def test_accept_matching(value, other, outcome): | ||||||
|     assert ( |     assert bool(headers.Matched.parse(value).match(other)) is outcome | ||||||
|         headers.Accept.parse(value).match( |  | ||||||
|             other, |  | ||||||
|             allow_type_wildcard=allow_type, |  | ||||||
|             allow_subtype_wildcard=allow_subtype, |  | ||||||
|         ) |  | ||||||
|         is outcome |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize("value", ("foo/bar", "foo/*", "*/*")) | @pytest.mark.parametrize("value", ("foo/bar", "foo/*", "*/*")) | ||||||
| def test_value_in_accept(value): | def test_value_in_accept(value): | ||||||
|     acceptable = headers.parse_accept(value) |     acceptable = headers.parse_accept(value) | ||||||
|     assert "foo/bar" in acceptable |     assert acceptable.match("foo/bar") | ||||||
|     assert "foo/*" in acceptable |     assert acceptable.match("foo/*") | ||||||
|     assert "*/*" in acceptable |     assert acceptable.match("*/*") | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize("value", ("foo/bar", "foo/*")) | @pytest.mark.parametrize("value", ("foo/bar", "foo/*")) | ||||||
| def test_value_not_in_accept(value): | def test_value_not_in_accept(value): | ||||||
|     acceptable = headers.parse_accept(value) |     acceptable = headers.parse_accept(value) | ||||||
|     assert "no/match" not in acceptable |     assert not acceptable.match("no/match") | ||||||
|     assert "no/*" not in acceptable |     assert not acceptable.match("no/*") | ||||||
|  |     assert "*/*" not in acceptable | ||||||
|  |     assert "*/bar" not in acceptable | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.parametrize( | @pytest.mark.parametrize( | ||||||
| @@ -365,6 +310,160 @@ def test_value_not_in_accept(value): | |||||||
|         ), |         ), | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
| def test_browser_headers(header, expected): | def test_browser_headers_general(header, expected): | ||||||
|     request = Request(b"/", {"accept": header}, "1.1", "GET", None, None) |     request = Request(b"/", {"accept": header}, "1.1", "GET", None, None) | ||||||
|     assert request.accept == expected |     assert [str(item) for item in request.accept] == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "header,expected", | ||||||
|  |     ( | ||||||
|  |         ( | ||||||
|  |             "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",  # noqa: E501 | ||||||
|  |             [ | ||||||
|  |                 ("text/html", 1.0), | ||||||
|  |                 ("application/xhtml+xml", 1.0), | ||||||
|  |                 ("image/avif", 1.0), | ||||||
|  |                 ("image/webp", 1.0), | ||||||
|  |                 ("application/xml", 0.9), | ||||||
|  |                 ("*/*", 0.8), | ||||||
|  |             ], | ||||||
|  |         ), | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  | def test_browser_headers_specific(header, expected): | ||||||
|  |     mimes = [e[0] for e in expected] | ||||||
|  |     qs = [e[1] for e in expected] | ||||||
|  |     request = Request(b"/", {"accept": header}, "1.1", "GET", None, None) | ||||||
|  |     assert request.accept == mimes | ||||||
|  |     for a, m, q in zip(request.accept, mimes, qs): | ||||||
|  |         assert a == m | ||||||
|  |         assert a.mime == m | ||||||
|  |         assert a.q == q | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "raw", | ||||||
|  |     ( | ||||||
|  |         "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8", | ||||||
|  |         "application/xml;q=0.9, */*;q=0.8, text/html, application/xhtml+xml", | ||||||
|  |         ( | ||||||
|  |             "foo/bar;q=0.9, */*;q=0.8, text/html=0.8, " | ||||||
|  |             "text/plain, application/xhtml+xml" | ||||||
|  |         ), | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  | def test_accept_ordering(raw): | ||||||
|  |     """Should sort by q but also be stable.""" | ||||||
|  |     accept = headers.parse_accept(raw) | ||||||
|  |     assert accept[0].type == "text" | ||||||
|  |     raw1 = ", ".join(str(a) for a in accept) | ||||||
|  |     accept = headers.parse_accept(raw1) | ||||||
|  |     raw2 = ", ".join(str(a) for a in accept) | ||||||
|  |     assert raw1 == raw2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_not_accept_wildcard(): | ||||||
|  |     accept = headers.parse_accept("*/*, foo/*, */bar, foo/bar;q=0.1") | ||||||
|  |     assert not accept.match( | ||||||
|  |         "text/html", "foo/foo", "bar/bar", accept_wildcards=False | ||||||
|  |     ) | ||||||
|  |     # Should ignore wildcards in accept but still matches them from mimes | ||||||
|  |     m = accept.match("text/plain", "*/*", accept_wildcards=False) | ||||||
|  |     assert m.mime == "*/*" | ||||||
|  |     assert m.match("*/*") | ||||||
|  |     assert m.header == "foo/bar" | ||||||
|  |     assert not accept.match( | ||||||
|  |         "text/html", "foo/foo", "bar/bar", accept_wildcards=False | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_accept_misc(): | ||||||
|  |     header = ( | ||||||
|  |         "foo/bar;q=0.0, */plain;param=123, text/plain, text/*, foo/bar;q=0.5" | ||||||
|  |     ) | ||||||
|  |     a = headers.parse_accept(header) | ||||||
|  |     assert repr(a) == ( | ||||||
|  |         "[*/plain;param=123, text/plain, text/*, " | ||||||
|  |         "foo/bar;q=0.5, foo/bar;q=0.0]" | ||||||
|  |     )  # noqa: E501 | ||||||
|  |     assert str(a) == ( | ||||||
|  |         "*/plain;param=123, text/plain, text/*, " | ||||||
|  |         "foo/bar;q=0.5, foo/bar;q=0.0" | ||||||
|  |     )  # noqa: E501 | ||||||
|  |     # q=1 types don't match foo/bar but match the two others, | ||||||
|  |     # text/* comes first and matches */plain because it | ||||||
|  |     # comes first in the header | ||||||
|  |     m = a.match("foo/bar", "text/*", "text/plain") | ||||||
|  |     assert repr(m) == "<text/* matched */plain;param=123>" | ||||||
|  |     assert m == "text/*" | ||||||
|  |     assert m.mime == "text/*" | ||||||
|  |     assert m.header.mime == "*/plain" | ||||||
|  |     assert m.header.type == "*" | ||||||
|  |     assert m.header.subtype == "plain" | ||||||
|  |     assert m.header.q == 1.0 | ||||||
|  |     assert m.header.params == dict(param="123") | ||||||
|  |     # Matches object against another Matched object (by mime and header) | ||||||
|  |     assert m == a.match("text/*") | ||||||
|  |     # Against unsupported type falls back to object id matching | ||||||
|  |     assert m != 123 | ||||||
|  |     # Matches the highest q value | ||||||
|  |     m = a.match("foo/bar") | ||||||
|  |     assert repr(m) == "<foo/bar matched foo/bar;q=0.5>" | ||||||
|  |     assert m == "foo/bar" | ||||||
|  |     assert m == "foo/bar;q=0.5" | ||||||
|  |     # Matching nothing special case | ||||||
|  |     m = a.match() | ||||||
|  |     assert m == "" | ||||||
|  |     assert m.header is None | ||||||
|  |     # No header means anything | ||||||
|  |     a = headers.parse_accept(None) | ||||||
|  |     assert a == ["*/*"] | ||||||
|  |     assert a.match("foo/bar") | ||||||
|  |     # Empty header means nothing | ||||||
|  |     a = headers.parse_accept("") | ||||||
|  |     assert a == [] | ||||||
|  |     assert not a.match("foo/bar") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "headers,expected", | ||||||
|  |     ( | ||||||
|  |         ({"foo": "bar"}, "bar"), | ||||||
|  |         ((("foo", "bar"), ("foo", "baz")), "bar,baz"), | ||||||
|  |         ({}, ""), | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  | def test_field_simple_accessor(headers, expected): | ||||||
|  |     request = make_request(headers) | ||||||
|  |     assert request.headers.foo == request.headers.foo_ == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "headers,expected", | ||||||
|  |     ( | ||||||
|  |         ({"foo-bar": "bar"}, "bar"), | ||||||
|  |         ((("foo-bar", "bar"), ("foo-bar", "baz")), "bar,baz"), | ||||||
|  |     ), | ||||||
|  | ) | ||||||
|  | def test_field_hyphenated_accessor(headers, expected): | ||||||
|  |     request = make_request(headers) | ||||||
|  |     assert request.headers.foo_bar == request.headers.foo_bar_ == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_bad_accessor(): | ||||||
|  |     request = make_request({}) | ||||||
|  |     msg = "'Header' object has no attribute '_foo'" | ||||||
|  |     with pytest.raises(AttributeError, match=msg): | ||||||
|  |         request.headers._foo | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_multiple_fields_accessor(app: Sanic): | ||||||
|  |     @app.get("") | ||||||
|  |     async def handler(request: Request): | ||||||
|  |         return json({"field": request.headers.example_field}) | ||||||
|  |  | ||||||
|  |     _, response = app.test_client.get( | ||||||
|  |         "/", headers=(("Example-Field", "Foo, Bar"), ("Example-Field", "Baz")) | ||||||
|  |     ) | ||||||
|  |     assert response.json["field"] == "Foo, Bar,Baz" | ||||||
|   | |||||||
| @@ -150,33 +150,47 @@ def test_request_accept(): | |||||||
|     async def get(request): |     async def get(request): | ||||||
|         return response.empty() |         return response.empty() | ||||||
|  |  | ||||||
|  |     header_value = "text/plain;format=flowed, text/plain, text/*, */*" | ||||||
|     request, _ = app.test_client.get( |     request, _ = app.test_client.get( | ||||||
|         "/", |         "/", | ||||||
|         headers={ |         headers={"Accept": header_value}, | ||||||
|             "Accept": "text/*, text/plain, text/plain;format=flowed, */*" |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     assert request.accept == [ |     assert str(request.accept) == header_value | ||||||
|  |     match = request.accept.match( | ||||||
|  |         "*/*;format=flowed", | ||||||
|         "text/plain;format=flowed", |         "text/plain;format=flowed", | ||||||
|         "text/plain", |         "text/plain", | ||||||
|         "text/*", |         "text/*", | ||||||
|         "*/*", |         "*/*", | ||||||
|     ] |     ) | ||||||
|  |     assert match == "*/*;format=flowed" | ||||||
|  |     assert match.header.mime == "text/plain" | ||||||
|  |     assert match.header.params == {"format": "flowed"} | ||||||
|  |  | ||||||
|  |     header_value = ( | ||||||
|  |         "text/plain; q=0.5,   text/html, text/x-dvi; q=0.8, text/x-c" | ||||||
|  |     ) | ||||||
|     request, _ = app.test_client.get( |     request, _ = app.test_client.get( | ||||||
|         "/", |         "/", | ||||||
|         headers={ |         headers={"Accept": header_value}, | ||||||
|             "Accept": ( |  | ||||||
|                 "text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c" |  | ||||||
|             ) |  | ||||||
|         }, |  | ||||||
|     ) |     ) | ||||||
|     assert request.accept == [ |     assert [str(i) for i in request.accept] == [ | ||||||
|         "text/html", |         "text/html", | ||||||
|         "text/x-c", |         "text/x-c", | ||||||
|         "text/x-dvi; q=0.8", |         "text/x-dvi;q=0.8", | ||||||
|         "text/plain; q=0.5", |         "text/plain;q=0.5", | ||||||
|     ] |     ] | ||||||
|  |     match = request.accept.match( | ||||||
|  |         "application/json", | ||||||
|  |         "text/plain",  # Has lower q in accept header | ||||||
|  |         "text/html;format=flowed",  # Params mismatch | ||||||
|  |         "text/*",  # Matches | ||||||
|  |         "*/*", | ||||||
|  |     ) | ||||||
|  |     assert match == "text/*" | ||||||
|  |     assert match.header.mime == "text/html" | ||||||
|  |     assert match.header.q == 1.0 | ||||||
|  |     assert not match.header.params | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_bad_url_parse(): | def test_bad_url_parse(): | ||||||
|   | |||||||
| @@ -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}") | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user