From b958cdc1518e12c99820c5efcda3e2c1352166d0 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Fri, 29 Jan 2021 16:19:10 +0200 Subject: [PATCH] Additonal annotations --- Makefile | 3 +- docs/conf.py | 7 +- docs/index.rst | 2 +- docs/sanic/api_reference.rst | 41 ++++------- sanic/__init__.py | 13 +++- sanic/config.py | 19 +++++- sanic/cookies.py | 10 +-- sanic/errorpages.py | 60 +++++++++++++++-- sanic/exceptions.py | 66 ++++++++++++++++-- sanic/helpers.py | 3 +- sanic/http.py | 127 ++++++++++++++++++++++++++--------- sanic/log.py | 11 +++ sanic/request.py | 110 +++++++++++++++++++++++------- sanic/response.py | 7 +- sanic/server.py | 29 ++++++-- tests/test_static.py | 5 +- 16 files changed, 394 insertions(+), 119 deletions(-) diff --git a/Makefile b/Makefile index 3f7c9692..30bbbf93 100644 --- a/Makefile +++ b/Makefile @@ -70,9 +70,10 @@ endif black: black --config ./.black.toml sanic tests -fix-import: black +isort: isort sanic tests +pretty: black isort docs-clean: cd docs && make clean diff --git a/docs/conf.py b/docs/conf.py index 7531810e..eeaa91f4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -156,7 +156,12 @@ epub_exclude_files = ["search.html"] suppress_warnings = ["image.nonlocal_uri"] -autodoc_member_order = "bysource" + +autodoc_typehints = "description" +autodoc_default_options = { + "member-order": "groupwise", +} + # app setup hook def setup(app): diff --git a/docs/index.rst b/docs/index.rst index 5b7e8746..8a9657d9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,7 @@ Guides ====== .. toctree:: - :maxdepth: 3 + :maxdepth: 4 sanic/api_reference diff --git a/docs/sanic/api_reference.rst b/docs/sanic/api_reference.rst index 2df73f00..08208da5 100644 --- a/docs/sanic/api_reference.rst +++ b/docs/sanic/api_reference.rst @@ -31,109 +31,92 @@ sanic.compat .. automodule:: sanic.compat :members: - sanic.config ------------ .. automodule:: sanic.config :members: - :undoc-members: - -sanic.constants ---------------- - -.. automodule:: sanic.constants - :members: - :undoc-members: sanic.cookies ------------- .. automodule:: sanic.cookies :members: - :undoc-members: + +sanic.errorpages +---------------- + +.. automodule:: sanic.errorpages + :members: sanic.exceptions ---------------- .. automodule:: sanic.exceptions :members: - :undoc-members: sanic.handlers -------------- .. automodule:: sanic.handlers :members: - :undoc-members: + +sanic.http +---------- + +.. automodule:: sanic.http + :members: sanic.log --------- .. automodule:: sanic.log :members: - :undoc-members: sanic.request ------------- .. automodule:: sanic.request :members: - :undoc-members: sanic.response -------------- .. automodule:: sanic.response :members: - :undoc-members: sanic.router ------------ .. automodule:: sanic.router :members: - :undoc-members: sanic.server ------------ .. automodule:: sanic.server :members: - :undoc-members: sanic.static ------------ .. automodule:: sanic.static :members: - :undoc-members: sanic.views ----------- .. automodule:: sanic.views :members: - :undoc-members: sanic.websocket --------------- .. automodule:: sanic.websocket :members: - :undoc-members: sanic.worker ------------ .. automodule:: sanic.worker :members: - :undoc-members: - - -Module contents ---------------- - -.. automodule:: sanic - :members: - :undoc-members: diff --git a/sanic/__init__.py b/sanic/__init__.py index 5fcb0fe8..8ba461fd 100644 --- a/sanic/__init__.py +++ b/sanic/__init__.py @@ -1,6 +1,17 @@ from sanic.__version__ import __version__ from sanic.app import Sanic from sanic.blueprints import Blueprint +from sanic.request import Request +from sanic.response import HTTPResponse, html, json, text -__all__ = ["Sanic", "Blueprint", "__version__"] +__all__ = ( + "__version__", + "Sanic", + "Blueprint", + "HTTPResponse", + "Request", + "html", + "json", + "text", +) diff --git a/sanic/config.py b/sanic/config.py index 64e36fc4..f14dd489 100644 --- a/sanic/config.py +++ b/sanic/config.py @@ -66,7 +66,16 @@ class Config(dict): def load_environment_vars(self, prefix=SANIC_PREFIX): """ Looks for prefixed environment variables and applies - them to the configuration if present. + them to the configuration if present. This is called automatically when + Sanic starts up to load environment variables into config. + + It will automatically hyrdate the following types: + + - ``int`` + - ``float`` + - ``bool`` + + Anything else will be imported as a ``str``. """ for k, v in environ.items(): if k.startswith(prefix): @@ -86,7 +95,9 @@ class Config(dict): """ Update app.config. - ..note:: only upper case settings are considered. + .. note:: + + Only upper case settings are considered You can upload app config by providing path to py file holding settings. @@ -102,7 +113,7 @@ class Config(dict): config.update_config("${some}/py/file") Yes you can put environment variable here, but they must be provided - in format: ${some_env_var}, and mark that $some_env_var is treated + in format: ``${some_env_var}``, and mark that ``$some_env_var`` is treated as plain string. You can upload app config by providing dict holding settings. @@ -122,6 +133,8 @@ class Config(dict): B = 2 config.update_config(C) + + `See user guide `__ """ if isinstance(config, (bytes, str, Path)): diff --git a/sanic/cookies.py b/sanic/cookies.py index 5387fcc5..993ce352 100644 --- a/sanic/cookies.py +++ b/sanic/cookies.py @@ -2,6 +2,7 @@ import re import string from datetime import datetime +from typing import Dict DEFAULT_MAX_AGE = 0 @@ -41,16 +42,17 @@ _is_legal_key = re.compile("[%s]+" % re.escape(_LegalChars)).fullmatch class CookieJar(dict): - """CookieJar dynamically writes headers as cookies are added and removed + """ + CookieJar dynamically writes headers as cookies are added and removed It gets around the limitation of one header per name by using the MultiHeader class to provide a unique key that encodes to Set-Cookie. """ def __init__(self, headers): super().__init__() - self.headers = headers - self.cookie_headers = {} - self.header_key = "Set-Cookie" + self.headers: Dict[str, str] = headers + self.cookie_headers: Dict[str, str] = {} + self.header_key: str = "Set-Cookie" def __setitem__(self, key, value): # If this cookie doesn't exist, add it to the header keys diff --git a/sanic/errorpages.py b/sanic/errorpages.py index c196a11a..325a3379 100644 --- a/sanic/errorpages.py +++ b/sanic/errorpages.py @@ -1,3 +1,17 @@ +""" +Sanic `provides a pattern `_ +for providing a response when an exception occurs. However, if you do no handle +an exception, it will provide a fallback. There are three fallback types: + +- HTML - *default* +- Text +- JSON + +Setting ``app.config.FALLBACK_ERROR_FORMAT = "auto"`` will enable a switch that +will attempt to provide an appropriate response format based upon the +request type. +""" + import sys import typing as t @@ -26,6 +40,10 @@ FALLBACK_STATUS = 500 class BaseRenderer: + """ + Base class that all renderers must inherit from. + """ + def __init__(self, request, exception, debug): self.request = request self.exception = exception @@ -54,7 +72,13 @@ class BaseRenderer: status_text = STATUS_CODES.get(self.status, b"Error Occurred").decode() return f"{self.status} — {status_text}" - def render(self): + def render(self) -> str: + """ + Outputs the exception as a ``str`` for response. + + :return: The formatted exception + :rtype: str + """ output = ( self.full if self.debug and not getattr(self.exception, "quiet", False) @@ -62,14 +86,28 @@ class BaseRenderer: ) return output() - def minimal(self): # noqa + def minimal(self) -> str: # noqa + """ + Provide a formatted message that is meant to not show any sensitive + data or details. + """ raise NotImplementedError - def full(self): # noqa + def full(self) -> str: # noqa + """ + Provide a formatted message that has all details and is mean to be used + primarily for debugging and non-production environments. + """ raise NotImplementedError class HTMLRenderer(BaseRenderer): + """ + Render an exception as HTML. + + The default fallback type. + """ + TRACEBACK_STYLE = """ html { font-family: sans-serif } h2 { color: #888; } @@ -172,6 +210,10 @@ class HTMLRenderer(BaseRenderer): class TextRenderer(BaseRenderer): + """ + Render an exception as plain text. + """ + OUTPUT_TEXT = "{title}\n{bar}\n{text}\n\n{body}" SPACER = " " @@ -231,6 +273,10 @@ class TextRenderer(BaseRenderer): class JSONRenderer(BaseRenderer): + """ + Render an exception as JSON. + """ + def full(self): output = self._generate_output(full=True) return json(output, status=self.status, dumps=dumps) @@ -280,7 +326,9 @@ class JSONRenderer(BaseRenderer): def escape(text): - """Minimal HTML escaping, not for attribute values (unlike html.escape).""" + """ + Minimal HTML escaping, not for attribute values (unlike html.escape). + """ return f"{text}".replace("&", "&").replace("<", "<") @@ -303,7 +351,9 @@ def exception_response( debug: bool, renderer: t.Type[t.Optional[BaseRenderer]] = None, ) -> HTTPResponse: - """Render a response for the default FALLBACK exception handler""" + """ + Render a response for the default FALLBACK exception handler. + """ if not renderer: renderer = HTMLRenderer diff --git a/sanic/exceptions.py b/sanic/exceptions.py index 35d19c3b..e65bd4f8 100644 --- a/sanic/exceptions.py +++ b/sanic/exceptions.py @@ -1,3 +1,5 @@ +from typing import Optional, Union + from sanic.helpers import STATUS_CODES @@ -33,16 +35,28 @@ class SanicException(Exception): @add_status_code(404) class NotFound(SanicException): + """ + **Status**: 404 Not Found + """ + pass @add_status_code(400) class InvalidUsage(SanicException): + """ + **Status**: 400 Bad Request + """ + pass @add_status_code(405) class MethodNotSupported(SanicException): + """ + **Status**: 405 Method Not Allowed + """ + def __init__(self, message, method, allowed_methods): super().__init__(message) self.headers = {"Allow": ", ".join(allowed_methods)} @@ -50,22 +64,38 @@ class MethodNotSupported(SanicException): @add_status_code(500) class ServerError(SanicException): + """ + **Status**: 500 Internal Server Error + """ + pass @add_status_code(503) class ServiceUnavailable(SanicException): - """The server is currently unavailable (because it is overloaded or - down for maintenance). Generally, this is a temporary state.""" + """ + **Status**: 503 Service Unavailable + + The server is currently unavailable (because it is overloaded or + down for maintenance). Generally, this is a temporary state. + """ pass class URLBuildError(ServerError): + """ + **Status**: 500 Internal Server Error + """ + pass class FileNotFound(NotFound): + """ + **Status**: 404 Not Found + """ + def __init__(self, message, path, relative_url): super().__init__(message) self.path = path @@ -87,15 +117,27 @@ class RequestTimeout(SanicException): @add_status_code(413) class PayloadTooLarge(SanicException): + """ + **Status**: 413 Payload Too Large + """ + pass class HeaderNotFound(InvalidUsage): + """ + **Status**: 400 Bad Request + """ + pass @add_status_code(416) class ContentRangeError(SanicException): + """ + **Status**: 416 Range Not Satisfiable + """ + def __init__(self, message, content_range): super().__init__(message) self.headers = {"Content-Range": f"bytes */{content_range.total}"} @@ -103,15 +145,27 @@ class ContentRangeError(SanicException): @add_status_code(417) class HeaderExpectationFailed(SanicException): + """ + **Status**: 417 Expectation Failed + """ + pass @add_status_code(403) class Forbidden(SanicException): + """ + **Status**: 403 Forbidden + """ + pass class InvalidRangeType(ContentRangeError): + """ + **Status**: 416 Range Not Satisfiable + """ + pass @@ -123,7 +177,7 @@ class PyFileError(Exception): @add_status_code(401) class Unauthorized(SanicException): """ - Unauthorized exception (401 HTTP status code). + **Status**: 401 Unauthorized :param message: Message describing the exception. :param status_code: HTTP Status code. @@ -173,7 +227,7 @@ class LoadFileException(SanicException): pass -def abort(status_code, message=None): +def abort(status_code: int, message: Optional[Union[str, bytes]] = None): """ Raise an exception based on SanicException. Returns the HTTP response message appropriate for the given status code, unless provided. @@ -184,8 +238,8 @@ def abort(status_code, message=None): :param message: The HTTP response body. Defaults to the messages in """ if message is None: - message = STATUS_CODES.get(status_code) + msg: bytes = STATUS_CODES[status_code] # These are stored as bytes in the STATUS_CODES dict - message = message.decode("utf8") + message = msg.decode("utf8") sanic_exception = _sanic_exceptions.get(status_code, SanicException) raise sanic_exception(message=message, status_code=status_code) diff --git a/sanic/helpers.py b/sanic/helpers.py index 0ee80977..15ae7bf2 100644 --- a/sanic/helpers.py +++ b/sanic/helpers.py @@ -2,9 +2,10 @@ from importlib import import_module from inspect import ismodule +from typing import Dict -STATUS_CODES = { +STATUS_CODES: Dict[int, bytes] = { 100: b"Continue", 101: b"Switching Protocols", 102: b"Processing", diff --git a/sanic/http.py b/sanic/http.py index addebfd6..1954da3f 100644 --- a/sanic/http.py +++ b/sanic/http.py @@ -1,3 +1,13 @@ +from __future__ import annotations +from typing import ( + Optional, + TYPE_CHECKING, +) + +if TYPE_CHECKING: + from sanic.request import Request + from sanic.response import BaseHTTPResponse + from asyncio import CancelledError, sleep from enum import Enum @@ -15,6 +25,17 @@ from sanic.log import access_logger, logger class Stage(Enum): + """ + Enum for representing the stage of the request/response cycle + + | ``IDLE`` Waiting for request + | ``REQUEST`` Request headers being received + | ``HANDLER`` Headers done, handler running + | ``RESPONSE`` Response headers sent, body in progress + | ``FAILED`` Unrecoverable state (error while sending response) + | + """ + IDLE = 0 # Waiting for request REQUEST = 1 # Request headers being received HANDLER = 3 # Headers done, handler running @@ -26,6 +47,24 @@ HTTP_CONTINUE = b"HTTP/1.1 100 Continue\r\n\r\n" class Http: + """ + Internal helper for managing the HTTP request/response cycle + + :raises ServerError: + :raises PayloadTooLarge: + :raises Exception: + :raises InvalidUsage: + :raises HeaderExpectationFailed: + :raises RuntimeError: + :raises ServerError: + :raises ServerError: + :raises InvalidUsage: + :raises InvalidUsage: + :raises InvalidUsage: + :raises PayloadTooLarge: + :raises RuntimeError: + """ + __slots__ = [ "_send", "_receive_more", @@ -53,16 +92,16 @@ class Http: self._receive_more = protocol.receive_more self.recv_buffer = protocol.recv_buffer self.protocol = protocol - self.expecting_continue = False - self.stage = Stage.IDLE + self.expecting_continue: bool = False + self.stage: Stage = Stage.IDLE self.request_body = None self.request_bytes = None self.request_bytes_left = None self.request_max_size = protocol.request_max_size self.keep_alive = True self.head_only = None - self.request = None - self.response = None + self.request: Request = None + self.response: BaseHTTPResponse = None self.exception = None self.url = None self.upgrade_websocket = False @@ -72,7 +111,9 @@ class Http: return self.stage in (Stage.HANDLER, Stage.RESPONSE) async def http1(self): - """HTTP 1.1 connection handler""" + """ + HTTP 1.1 connection handler + """ while True: # As long as connection stays keep-alive try: # Receive and handle a request @@ -125,7 +166,9 @@ class Http: await self._receive_more() async def http1_request_header(self): - """Receive and parse request header into self.request.""" + """ + Receive and parse request header into self.request. + """ HEADER_MAX_SIZE = min(8192, self.request_max_size) # Receive until full header is in buffer buf = self.recv_buffer @@ -214,7 +257,9 @@ class Http: self.request, request.stream = request, self self.protocol.state["requests_count"] += 1 - async def http1_response_header(self, data, end_stream): + async def http1_response_header( + self, data: bytes, end_stream: bool + ) -> None: res = self.response # Compatibility with simple response body @@ -257,7 +302,7 @@ class Http: else: # Length not known, use chunked encoding headers["transfer-encoding"] = "chunked" - data = b"%x\r\n%b\r\n" % (size, data) if size else None + data = b"%x\r\n%b\r\n" % (size, data) if size else b"" self.response_func = self.http1_response_chunked if self.head_only: @@ -283,14 +328,20 @@ class Http: await self._send(ret) self.stage = Stage.IDLE if end_stream else Stage.RESPONSE - def head_response_ignored(self, data, end_stream): - """HEAD response: body data silently ignored.""" + def head_response_ignored(self, data: bytes, end_stream: bool) -> None: + """ + HEAD response: body data silently ignored. + """ if end_stream: self.response_func = None self.stage = Stage.IDLE - async def http1_response_chunked(self, data, end_stream): - """Format a part of response body in chunked encoding.""" + async def http1_response_chunked( + self, data: bytes, end_stream: bool + ) -> None: + """ + Format a part of response body in chunked encoding. + """ # Chunked encoding size = len(data) if end_stream: @@ -304,8 +355,12 @@ class Http: elif size: await self._send(b"%x\r\n%b\r\n" % (size, data)) - async def http1_response_normal(self, data: bytes, end_stream: bool): - """Format / keep track of non-chunked response.""" + async def http1_response_normal( + self, data: bytes, end_stream: bool + ) -> None: + """ + Format / keep track of non-chunked response. + """ bytes_left = self.response_bytes_left - len(data) if bytes_left <= 0: if bytes_left < 0: @@ -321,7 +376,10 @@ class Http: await self._send(data) self.response_bytes_left = bytes_left - async def error_response(self, exception): + async def error_response(self, exception: Exception) -> None: + """ + Handle response when exception encountered + """ # Disconnect after an error if in any other state than handler if self.stage is not Stage.HANDLER: self.keep_alive = False @@ -339,10 +397,13 @@ class Http: await app.handle_exception(self.request, exception) - def create_empty_request(self): - """Current error handling code needs a request object that won't exist + def create_empty_request(self) -> None: + """ + Current error handling code needs a request object that won't exist if an error occurred during before a request was received. Create a - bogus response for error handling use.""" + bogus response for error handling use. + """ + # FIXME: Avoid this by refactoring error handling and response code self.request = self.protocol.request_class( url_bytes=self.url.encode() if self.url else b"*", @@ -354,17 +415,10 @@ class Http: ) self.request.stream = self - def log_response(self): + def log_response(self) -> None: """ Helper method provided to enable the logging of responses in case if the :attr:`HttpProtocol.access_log` is enabled. - - :param response: Response generated for the current request - - :type response: :class:`sanic.response.HTTPResponse` or - :class:`sanic.response.StreamingHTTPResponse` - - :return: None """ req, res = self.request, self.response extra = { @@ -382,15 +436,20 @@ class Http: # Request methods async def __aiter__(self): - """Async iterate over request body.""" + """ + Async iterate over request body. + """ while self.request_body: data = await self.read() if data: yield data - async def read(self): - """Read some bytes of request body.""" + async def read(self) -> Optional[bytes]: + """ + Read some bytes of request body. + """ + # Send a 100-continue if needed if self.expecting_continue: self.expecting_continue = False @@ -440,7 +499,7 @@ class Http: # End of request body? if not self.request_bytes_left: self.request_body = None - return + return None # At this point we are good to read/return up to request_bytes_left if not buf: @@ -457,12 +516,14 @@ class Http: # Response methods - def respond(self, response): - """Initiate new streaming response. + def respond(self, response: BaseHTTPResponse) -> BaseHTTPResponse: + """ + Initiate new streaming response. Nothing is sent until the first send() call on the returned object, and calling this function multiple times will just alter the response to be - given.""" + given. + """ if self.stage is not Stage.HANDLER: self.stage = Stage.FAILED raise RuntimeError("Response already started") diff --git a/sanic/log.py b/sanic/log.py index 08fc835d..2e360835 100644 --- a/sanic/log.py +++ b/sanic/log.py @@ -54,5 +54,16 @@ LOGGING_CONFIG_DEFAULTS = dict( logger = logging.getLogger("sanic.root") +""" +General Sanic logger +""" + error_logger = logging.getLogger("sanic.error") +""" +Logger used by Sanic for error logging +""" + access_logger = logging.getLogger("sanic.access") +""" +Logger used by Sanic for access logging +""" diff --git a/sanic/request.py b/sanic/request.py index c6e4c28c..a4b3859c 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -1,14 +1,30 @@ +from __future__ import annotations +from typing import ( + Optional, + TYPE_CHECKING, + DefaultDict, + Dict, + List, + NamedTuple, + Tuple, +) + +if TYPE_CHECKING: + from sanic.server import ConnInfo + from sanic.app import Sanic + +from asyncio.transports import BaseTransport import email.utils import uuid -from collections import defaultdict, namedtuple +from collections import defaultdict from http.cookies import SimpleCookie from types import SimpleNamespace from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse from httptools import parse_url # type: ignore -from sanic.compat import CancelledErrors +from sanic.compat import CancelledErrors, Header from sanic.exceptions import InvalidUsage from sanic.headers import ( parse_content_header, @@ -47,7 +63,9 @@ class RequestParameters(dict): class Request: - """Properties of an HTTP request such as URL, headers, etc.""" + """ + Properties of an HTTP request such as URL, headers, etc. + """ __slots__ = ( "__weakref__", @@ -80,7 +98,15 @@ class Request: "version", ) - def __init__(self, url_bytes, headers, version, method, transport, app): + def __init__( + self, + url_bytes: bytes, + headers: Header, + version: str, + method: str, + transport: BaseTransport, + app: Sanic, + ): self.raw_url = url_bytes # TODO: Content-Encoding detection self._parsed_url = parse_url(url_bytes) @@ -94,18 +120,22 @@ class Request: # Init but do not inhale self.body = b"" - self.conn_info = None + self.conn_info: Optional[ConnInfo] = None self.ctx = SimpleNamespace() - self.name = None + self.name: Optional[str] = None self.parsed_forwarded = None self.parsed_json = None self.parsed_form = None self.parsed_files = None - self.parsed_args = defaultdict(RequestParameters) - self.parsed_not_grouped_args = defaultdict(list) + self.parsed_args: DefaultDict[ + Tuple[bool, bool, str, str], RequestParameters + ] = defaultdict(RequestParameters) + self.parsed_not_grouped_args: DefaultDict[ + Tuple[bool, bool, str, str], List[Tuple[str, str]] + ] = defaultdict(list) self.uri_template = None self.request_middleware_started = False - self._cookies = None + self._cookies: Dict[str, str] = {} self.stream = None self.endpoint = None @@ -350,11 +380,11 @@ class Request: query_args = property(get_query_args) @property - def cookies(self): + def cookies(self) -> Dict[str, str]: if self._cookies is None: cookie = self.headers.get("Cookie") if cookie is not None: - cookies = SimpleCookie() + cookies: SimpleCookie = SimpleCookie() cookies.load(cookie) self._cookies = { name: cookie.value for name, cookie in cookies.items() @@ -364,27 +394,35 @@ class Request: return self._cookies @property - def content_type(self): + def content_type(self) -> str: + """ + :return: Content-Type header form the request + :rtype: str + """ return self.headers.get("Content-Type", DEFAULT_HTTP_CONTENT_TYPE) @property def match_info(self): - """return matched info after resolving route""" + """ + :return: matched info after resolving route + """ return self.app.router.get(self)[2] # Transport properties (obtained from local interface only) @property - def ip(self): + def ip(self) -> str: """ :return: peer ip of the socket + :rtype: str """ return self.conn_info.client if self.conn_info else "" @property - def port(self): + def port(self) -> int: """ :return: peer port of the socket + :rtype: int """ return self.conn_info.client_port if self.conn_info else 0 @@ -477,39 +515,49 @@ class Request: @property def server_name(self) -> str: - """The hostname the client connected to, by `request.host`.""" + """ + The hostname the client connected to, by ``request.host``. + """ return parse_host(self.host)[0] or "" @property def server_port(self) -> int: """ - The port the client connected to, by forwarded `port` or - `request.host`. + The port the client connected to, by forwarded ``port`` or + ``request.host``. - Default port is returned as 80 and 443 based on `request.scheme`. + Default port is returned as 80 and 443 based on ``request.scheme``. """ port = self.forwarded.get("port") or parse_host(self.host)[1] return port or (80 if self.scheme in ("http", "ws") else 443) @property def server_path(self) -> str: - """Full path of current URL. Uses proxied or local path.""" + """ + Full path of current URL. Uses proxied or local path. + """ return self.forwarded.get("path") or self.path @property - def query_string(self): + def query_string(self) -> str: + """ + Representation of the requested query + """ if self._parsed_url.query: return self._parsed_url.query.decode("utf-8") else: return "" @property - def url(self): + def url(self) -> str: + """ + The URL + """ return urlunparse( (self.scheme, self.host, self.path, None, self.query_string, None) ) - def url_for(self, view_name, **kwargs): + def url_for(self, view_name: str, **kwargs) -> str: """ Same as :func:`sanic.Sanic.url_for`, but automatically determine `scheme` and `netloc` base on the request. Since this method is aiming @@ -542,11 +590,23 @@ class Request: ) -File = namedtuple("File", ["type", "body", "name"]) +class File(NamedTuple): + """ + Model for defining a file + + :param type: The mimetype, defaults to text/plain + :param body: Bytes of the file + :param name: The filename + """ + + type: str + body: bytes + name: str def parse_multipart_form(body, boundary): - """Parse a request body and returns fields and files + """ + Parse a request body and returns fields and files :param body: bytes request body :param boundary: bytes multipart boundary diff --git a/sanic/response.py b/sanic/response.py index 3fff76c8..c5472913 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -1,12 +1,14 @@ from functools import partial from mimetypes import guess_type from os import path +from typing import Optional from urllib.parse import quote_plus from warnings import warn from sanic.compat import Header, open_async from sanic.cookies import CookieJar from sanic.helpers import has_message_body, remove_entity_headers +from sanic.http import Http try: @@ -21,7 +23,10 @@ except ImportError: class BaseHTTPResponse: def __init__(self): - self.asgi = False + self.asgi: bool = False + self.body: Optional[bytes] = None + self.stream: Http = None + self.status: int = None def _encode_body(self, data): if data is None: diff --git a/sanic/server.py b/sanic/server.py index 3564f4fa..92dbe968 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -1,3 +1,21 @@ +from __future__ import annotations +from typing import ( + Optional, + TYPE_CHECKING, + DefaultDict, + Dict, + List, + NamedTuple, + Tuple, + Dict, + Type, + Union, +) + +if TYPE_CHECKING: + from sanic.http import Http + from sanic.app import Sanic + import asyncio import multiprocessing import os @@ -6,6 +24,7 @@ import socket import stat import sys +from sanic.http import Stage from asyncio import CancelledError from functools import partial from inspect import isawaitable @@ -13,15 +32,13 @@ from ipaddress import ip_address from signal import SIG_IGN, SIGINT, SIGTERM, Signals from signal import signal as signal_func from time import monotonic as current_time -from typing import Dict, Type, Union +from sanic.request import Request from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows from sanic.config import Config from sanic.exceptions import RequestTimeout, ServiceUnavailable -from sanic.http import Http, Stage from sanic.log import logger -from sanic.request import Request - +from asyncio.transports import BaseTransport try: import uvloop # type: ignore @@ -49,7 +66,7 @@ class ConnInfo: "ssl", ) - def __init__(self, transport, unix=None): + def __init__(self, transport: BaseTransport, unix=None): self.ssl = bool(transport.get_extra_info("sslcontext")) self.server = self.client = "" self.server_port = self.client_port = 0 @@ -126,7 +143,7 @@ class HttpProtocol(asyncio.Protocol): asyncio.set_event_loop(loop) self.loop = loop deprecated_loop = self.loop if sys.version_info < (3, 7) else None - self.app = app + self.app: Sanic = app self.url = None self.transport = None self.conn_info = None diff --git a/tests/test_static.py b/tests/test_static.py index 23ba05d7..78c114b9 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -1,5 +1,6 @@ import inspect import os + from pathlib import Path from time import gmtime, strftime @@ -93,8 +94,8 @@ def test_static_file_pathlib(app, static_file_directory, file_name): [b"test.file", b"decode me.txt", b"python.png"], ) def test_static_file_bytes(app, static_file_directory, file_name): - bsep = os.path.sep.encode('utf-8') - file_path = static_file_directory.encode('utf-8') + bsep + file_name + bsep = os.path.sep.encode("utf-8") + file_path = static_file_directory.encode("utf-8") + bsep + file_name app.static("/testing.file", file_path) request, response = app.test_client.get("/testing.file") assert response.status == 200