diff --git a/.travis.yml b/.travis.yml index d304c701..4553e325 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,6 +21,12 @@ matrix: dist: xenial sudo: true name: "Python 3.7 without Extensions" + - env: TOX_ENV=type-checking + python: 3.6 + name: "Python 3.6 Type checks" + - env: TOX_ENV=type-checking + python: 3.7 + name: "Python 3.7 Type checks" - env: TOX_ENV=lint python: 3.6 name: "Python 3.6 Linter checks" diff --git a/sanic/__main__.py b/sanic/__main__.py index 73de3265..11320305 100644 --- a/sanic/__main__.py +++ b/sanic/__main__.py @@ -1,5 +1,6 @@ from argparse import ArgumentParser from importlib import import_module +from typing import Any, Dict, Optional from sanic.app import Sanic from sanic.log import logger @@ -35,7 +36,10 @@ if __name__ == "__main__": ) ) if args.cert is not None or args.key is not None: - ssl = {"cert": args.cert, "key": args.key} + ssl = { + "cert": args.cert, + "key": args.key, + } # type: Optional[Dict[str, Any]] else: ssl = None diff --git a/sanic/app.py b/sanic/app.py index 92e1a8dd..5cf06c07 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -11,7 +11,7 @@ from inspect import getmodulename, isawaitable, signature, stack from socket import socket from ssl import Purpose, SSLContext, create_default_context from traceback import format_exc -from typing import Any, Optional, Type, Union +from typing import Any, Dict, Optional, Type, Union from urllib.parse import urlencode, urlunparse from sanic import reloader_helpers @@ -768,7 +768,7 @@ class Sanic: URLBuildError """ # find the route by the supplied view name - kw = {} + kw: Dict[str, str] = {} # special static files url_for if view_name == "static": kw.update(name=kwargs.pop("name", "static")) diff --git a/sanic/asgi.py b/sanic/asgi.py index 8f7e5770..f9e49005 100644 --- a/sanic/asgi.py +++ b/sanic/asgi.py @@ -2,9 +2,23 @@ import asyncio import warnings from inspect import isawaitable -from typing import Any, Awaitable, Callable, MutableMapping, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + MutableMapping, + Optional, + Tuple, + Union, +) from urllib.parse import quote +from requests_async import ASGISession # type: ignore + +import sanic.app # noqa + from sanic.compat import Header from sanic.exceptions import InvalidUsage, ServerError from sanic.log import logger @@ -54,6 +68,8 @@ class MockProtocol: class MockTransport: + _protocol: Optional[MockProtocol] + def __init__( self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend ) -> None: @@ -68,11 +84,12 @@ class MockTransport: self._protocol = MockProtocol(self, self.loop) return self._protocol - def get_extra_info(self, info: str) -> Union[str, bool]: + def get_extra_info(self, info: str) -> Union[str, bool, None]: if info == "peername": return self.scope.get("server") elif info == "sslcontext": return self.scope.get("scheme") in ["https", "wss"] + return None def get_websocket_connection(self) -> WebSocketConnection: try: @@ -172,6 +189,13 @@ class Lifespan: class ASGIApp: + sanic_app: Union[ASGISession, "sanic.app.Sanic"] + request: Request + transport: MockTransport + do_stream: bool + lifespan: Lifespan + ws: Optional[WebSocketConnection] + def __init__(self) -> None: self.ws = None @@ -182,8 +206,8 @@ class ASGIApp: instance = cls() instance.sanic_app = sanic_app instance.transport = MockTransport(scope, receive, send) - instance.transport.add_task = sanic_app.loop.create_task instance.transport.loop = sanic_app.loop + setattr(instance.transport, "add_task", sanic_app.loop.create_task) headers = Header( [ @@ -286,8 +310,8 @@ class ASGIApp: """ Write the response. """ - headers = [] - cookies = {} + headers: List[Tuple[bytes, bytes]] = [] + cookies: Dict[str, str] = {} try: cookies = { v.key: v diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index 3850cd20..e6e0ebbb 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -56,7 +56,7 @@ class BlueprintGroup(MutableSequence): """ return self._blueprints[item] - def __setitem__(self, index: int, item: object) -> None: + def __setitem__(self, index, item) -> None: """ Abstract method implemented to turn the `BlueprintGroup` class into a list like object to support all the existing behavior. @@ -69,7 +69,7 @@ class BlueprintGroup(MutableSequence): """ self._blueprints[index] = item - def __delitem__(self, index: int) -> None: + def __delitem__(self, index) -> None: """ Abstract method implemented to turn the `BlueprintGroup` class into a list like object to support all the existing behavior. diff --git a/sanic/compat.py b/sanic/compat.py index 6ca7e344..f5475695 100644 --- a/sanic/compat.py +++ b/sanic/compat.py @@ -1,4 +1,4 @@ -from multidict import CIMultiDict +from multidict import CIMultiDict # type: ignore class Header(CIMultiDict): diff --git a/sanic/headers.py b/sanic/headers.py index 6c9fa221..142ab27b 100644 --- a/sanic/headers.py +++ b/sanic/headers.py @@ -1,10 +1,10 @@ import re -from typing import Dict, Iterable, Optional, Tuple +from typing import Dict, Iterable, List, Optional, Tuple, Union from urllib.parse import unquote -Options = Dict[str, str] # key=value fields in various headers +Options = Dict[str, Union[int, str]] # key=value fields in various headers OptionsIterable = Iterable[Tuple[str, str]] # May contain duplicate keys _token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"' @@ -35,7 +35,7 @@ def parse_content_header(value: str) -> Tuple[str, Options]: value = _firefox_quote_escape.sub("%22", value) pos = value.find(";") if pos == -1: - options = {} + options: Dict[str, Union[int, str]] = {} else: options = { m.group(1).lower(): m.group(2) or m.group(3).replace("%22", '"') @@ -67,7 +67,7 @@ def parse_forwarded(headers, config) -> Optional[Options]: return None # Loop over = elements from right to left sep = pos = None - options = [] + options: List[Tuple[str, str]] = [] found = False for m in _rparam.finditer(header[::-1]): # Start of new element? (on parser skips and non-semicolon right sep) @@ -101,8 +101,13 @@ def parse_xforwarded(headers, config) -> Optional[Options]: try: # Combine, split and filter multiple headers' entries forwarded_for = headers.getall(config.FORWARDED_FOR_HEADER) - proxies = (p.strip() for h in forwarded_for for p in h.split(",")) - proxies = [p for p in proxies if p] + proxies = [ + p + for p in ( + p.strip() for h in forwarded_for for p in h.split(",") + ) + if p + ] addr = proxies[-proxies_count] except (KeyError, IndexError): pass @@ -126,7 +131,7 @@ def parse_xforwarded(headers, config) -> Optional[Options]: def fwd_normalize(fwd: OptionsIterable) -> Options: """Normalize and convert values extracted from forwarded headers.""" - ret = {} + ret: Dict[str, Union[int, str]] = {} for key, val in fwd: if val is not None: try: @@ -164,4 +169,4 @@ def parse_host(host: str) -> Tuple[Optional[str], Optional[int]]: if not m: return None, None host, port = m.groups() - return host.lower(), port and int(port) + return host.lower(), int(port) if port is not None else None diff --git a/sanic/request.py b/sanic/request.py index 734ad0a3..690213a3 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -6,7 +6,7 @@ from collections import defaultdict, namedtuple from http.cookies import SimpleCookie from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse -from httptools import parse_url +from httptools import parse_url # type: ignore from sanic.exceptions import InvalidUsage from sanic.headers import ( @@ -19,9 +19,9 @@ from sanic.log import error_logger, logger try: - from ujson import loads as json_loads + from ujson import loads as json_loads # type: ignore except ImportError: - from json import loads as json_loads + from json import loads as json_loads # type: ignore DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream" EXPECT_HEADER = "EXPECT" diff --git a/sanic/response.py b/sanic/response.py index e339819f..91fb25f4 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -3,7 +3,7 @@ from mimetypes import guess_type from os import path from urllib.parse import quote_plus -from aiofiles import open as open_async +from aiofiles import open as open_async # type: ignore from sanic.compat import Header from sanic.cookies import CookieJar diff --git a/sanic/server.py b/sanic/server.py index f18a27d7..fa38e435 100644 --- a/sanic/server.py +++ b/sanic/server.py @@ -10,8 +10,8 @@ from signal import signal as signal_func from socket import SO_REUSEADDR, SOL_SOCKET, socket from time import time -from httptools import HttpRequestParser -from httptools.parser.errors import HttpParserError +from httptools import HttpRequestParser # type: ignore +from httptools.parser.errors import HttpParserError # type: ignore from sanic.compat import Header from sanic.exceptions import ( @@ -28,7 +28,7 @@ from sanic.response import HTTPResponse try: - import uvloop + import uvloop # type: ignore if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy): asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) diff --git a/sanic/static.py b/sanic/static.py index 104a820b..cc020bc7 100644 --- a/sanic/static.py +++ b/sanic/static.py @@ -4,7 +4,7 @@ from re import sub from time import gmtime, strftime from urllib.parse import unquote -from aiofiles.os import stat +from aiofiles.os import stat # type: ignore from sanic.exceptions import ( ContentRangeError, diff --git a/sanic/testing.py b/sanic/testing.py index 06d75fc1..f4925b7f 100644 --- a/sanic/testing.py +++ b/sanic/testing.py @@ -6,9 +6,9 @@ from json import JSONDecodeError from socket import socket from urllib.parse import unquote, urlsplit -import httpcore -import requests_async as requests -import websockets +import httpcore # type: ignore +import requests_async as requests # type: ignore +import websockets # type: ignore from sanic.asgi import ASGIApp from sanic.exceptions import MethodNotSupported @@ -288,6 +288,14 @@ class SanicASGIAdapter(requests.asgi.ASGIAdapter): # noqa request_complete = True return {"type": "http.request", "body": body_bytes} + request_complete = False + response_started = False + response_complete = False + raw_kwargs = {"content": b""} # type: typing.Dict[str, typing.Any] + template = None + context = None + return_value = None + async def send(message) -> None: nonlocal raw_kwargs, response_started, response_complete, template, context # noqa @@ -316,14 +324,6 @@ class SanicASGIAdapter(requests.asgi.ASGIAdapter): # noqa template = message["template"] context = message["context"] - request_complete = False - response_started = False - response_complete = False - raw_kwargs = {"content": b""} # type: typing.Dict[str, typing.Any] - template = None - context = None - return_value = None - try: return_value = await self.app(scope, receive, send) except BaseException as exc: diff --git a/sanic/views.py b/sanic/views.py index 3bfe3850..c4574b8d 100644 --- a/sanic/views.py +++ b/sanic/views.py @@ -1,3 +1,5 @@ +from typing import Any, Callable, List + from sanic.constants import HTTP_METHODS from sanic.exceptions import InvalidUsage @@ -37,7 +39,7 @@ class HTTPMethodView: To add any decorator you could set it into decorators variable """ - decorators = [] + decorators: List[Callable[[Callable[..., Any]], Callable[..., Any]]] = [] def dispatch_request(self, request, *args, **kwargs): handler = getattr(self, request.method.lower(), None) diff --git a/sanic/websocket.py b/sanic/websocket.py index f87188e4..60092836 100644 --- a/sanic/websocket.py +++ b/sanic/websocket.py @@ -1,13 +1,27 @@ -from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + MutableMapping, + Optional, + Union, +) -from httptools import HttpParserUpgrade -from websockets import ConnectionClosed # noqa -from websockets import InvalidHandshake, WebSocketCommonProtocol, handshake +from httptools import HttpParserUpgrade # type: ignore +from websockets import ( # type: ignore + ConnectionClosed, + InvalidHandshake, + WebSocketCommonProtocol, + handshake, +) from sanic.exceptions import InvalidUsage from sanic.server import HttpProtocol +__all__ = ["ConnectionClosed", "WebSocketProtocol", "WebSocketConnection"] + ASIMessage = MutableMapping[str, Any] @@ -125,14 +139,12 @@ class WebSocketConnection: self._receive = receive async def send(self, data: Union[str, bytes], *args, **kwargs) -> None: - message = {"type": "websocket.send"} + message: Dict[str, Union[str, bytes]] = {"type": "websocket.send"} - try: - data.decode() - except AttributeError: - message.update({"text": str(data)}) - else: + if isinstance(data, bytes): message.update({"bytes": data}) + else: + message.update({"text": str(data)}) await self._send(message) @@ -144,6 +156,8 @@ class WebSocketConnection: elif message["type"] == "websocket.disconnect": pass + return None + receive = recv async def accept(self) -> None: diff --git a/sanic/worker.py b/sanic/worker.py index ef8b2128..777f12cf 100644 --- a/sanic/worker.py +++ b/sanic/worker.py @@ -5,19 +5,19 @@ import signal import sys import traceback -import gunicorn.workers.base as base +import gunicorn.workers.base as base # type: ignore from sanic.server import HttpProtocol, Signal, serve, trigger_events from sanic.websocket import WebSocketProtocol try: - import ssl + import ssl # type: ignore except ImportError: - ssl = None + ssl = None # type: ignore try: - import uvloop + import uvloop # type: ignore asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) except ImportError: diff --git a/tox.ini b/tox.ini index f8138edd..9eca7f58 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,13 @@ commands = black --config ./.black.toml --check --verbose sanic/ isort --check-only --recursive sanic +[testenv:type-checking] +deps = + mypy + +commands = + mypy sanic + [testenv:check] deps = docutils