Skip to content

Commit

Permalink
feat: introduce type hints (MVP) (#1947)
Browse files Browse the repository at this point in the history
* chore: prepare for shipping type hints

* chore: rerun `black` and enable a new mypy env

* chore: use the correct Tox env name `mypy_tests`

* chore(typing): re-add `types-jsonschema` to the main `mypy` gate

* chore(typing): rename a shadowed parameter to make it obvious for `mypy`

* chore(typing): add more types to test `mypy` behaviour wrt cythonized modules

* feat(typing): add some more types (WiP)

* feat(typing): add more annotations to `app.py`

* feat(typing): add annotations to E2E server part

* fix(typing): adapt to the new default (`no_implicit_optional=True`)

* fix(typing): apply misc fixes to hopefully pass CI

* docs(typing): add a provisional newsfragment

* fix(typing): fix more `mypy` warnings/errors

* chore(app_helpers): improve typing of middleware prep

* refactor(typing): remove generics, type more stuff

* chore: bluen the code
  • Loading branch information
vytas7 committed Jul 8, 2023
1 parent 1e34bf4 commit 574a0d0
Show file tree
Hide file tree
Showing 26 changed files with 254 additions and 92 deletions.
3 changes: 2 additions & 1 deletion .coveragerc
@@ -1,14 +1,15 @@
[run]
branch = True
source = falcon
omit = falcon/tests*,falcon/cmd/bench.py,falcon/bench/*,falcon/vendor/*
omit = falcon/tests*,falcon/typing.py,falcon/cmd/bench.py,falcon/bench/*,falcon/vendor/*

parallel = True

[report]
show_missing = True
exclude_lines =
if TYPE_CHECKING:
if not TYPE_CHECKING:
pragma: nocover
pragma: no cover
pragma: no py39,py310 cover
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Expand Up @@ -27,6 +27,7 @@ jobs:
- "pep8-examples"
- "pep8-docstrings"
- "mypy"
- "mypy_tests"
- "py310"
- "py310_sans_msgpack"
- "py310_cython"
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Expand Up @@ -9,6 +9,7 @@ include README.rst
include AUTHORS
include LICENSE
include docs/conf.py docs/Makefile
include falcon/py.typed
graft docs/_static
graft docs/_templates
graft requirements
Expand Down
4 changes: 4 additions & 0 deletions docs/_newsfragments/1947.newandimproved.rst
@@ -0,0 +1,4 @@
Basic typing annotations have been added to the most commonly used functions of
Falcon's public interface to the package itself in order to better support
`mypy <https://www.mypy-lang.org/>`_ users without having to install any
third-party typeshed packages.
2 changes: 1 addition & 1 deletion e2e-tests/server/app.py
Expand Up @@ -10,7 +10,7 @@
STATIC = HERE.parent / 'static'


def create_app():
def create_app() -> falcon.asgi.App:
app = falcon.asgi.App()

hub = Hub()
Expand Down
8 changes: 6 additions & 2 deletions e2e-tests/server/chat.py
@@ -1,14 +1,18 @@
import re

from falcon.asgi import Request, WebSocket

from .hub import Hub


class Chat:
ALL = re.compile(r'^/all\s+(.+)$')
MSG = re.compile(r'^/msg\s+(\w+)\s+(.+)$')

def __init__(self, hub):
def __init__(self, hub: Hub):
self._hub = hub

async def on_websocket(self, req, ws, name):
async def on_websocket(self, req: Request, ws: WebSocket, name: str) -> None:
await ws.accept()

try:
Expand Down
23 changes: 12 additions & 11 deletions e2e-tests/server/hub.py
@@ -1,7 +1,8 @@
import asyncio
import typing
import uuid

from falcon.asgi import SSEvent
from falcon.asgi import Request, Response, SSEvent, WebSocket


class Emitter:
Expand All @@ -11,7 +12,7 @@ def __init__(self):
self._done = False
self._queue = asyncio.Queue()

async def events(self):
async def events(self) -> typing.AsyncGenerator[typing.Optional[SSEvent], None]:
try:
yield SSEvent(text='SSE CONNECTED')

Expand All @@ -28,7 +29,7 @@ async def events(self):
# TODO(vytas): Is there a more elegant way to detect a disconnect?
self._done = True

async def enqueue(self, message):
async def enqueue(self, message: str) -> None:
event = SSEvent(text=message, event_id=str(uuid.uuid4()))
await self._queue.put(event)

Expand All @@ -42,37 +43,37 @@ def __init__(self):
self._emitters = set()
self._users = {}

def _update_emitters(self):
def _update_emitters(self) -> set:
done = {emitter for emitter in self._emitters if emitter.done}
self._emitters.difference_update(done)
return self._emitters.copy()

def add_user(self, name, ws):
def add_user(self, name: str, ws: WebSocket) -> None:
self._users[name] = ws

def remove_user(self, name):
def remove_user(self, name: str) -> None:
self._users.pop(name, None)

async def broadcast(self, message):
async def broadcast(self, message: str) -> None:
for emitter in self._update_emitters():
await emitter.enqueue(message)

async def message(self, name, text):
async def message(self, name: str, text: str) -> None:
ws = self._users.get(name)
if ws:
# TODO(vytas): What if this overlaps with another ongoing send?
await ws.send_text(text)

def events(self):
def events(self) -> typing.AsyncGenerator[typing.Optional[SSEvent], None]:
emitter = Emitter()
self._update_emitters()
self._emitters.add(emitter)
return emitter.events()


class Events:
def __init__(self, hub):
def __init__(self, hub: Hub):
self._hub = hub

async def on_get(self, req, resp):
async def on_get(self, req: Request, resp: Response) -> None:
resp.sse = self._hub.events()
3 changes: 2 additions & 1 deletion e2e-tests/server/ping.py
@@ -1,10 +1,11 @@
from http import HTTPStatus

import falcon
from falcon.asgi import Request, Response


class Pong:
async def on_get(self, req, resp):
async def on_get(self, req: Request, resp: Response) -> None:
resp.content_type = falcon.MEDIA_TEXT
resp.text = 'PONG\n'
resp.status = HTTPStatus.OK
57 changes: 40 additions & 17 deletions falcon/app.py
Expand Up @@ -16,8 +16,10 @@

from functools import wraps
from inspect import iscoroutinefunction
import pathlib
import re
import traceback
from typing import Callable, Iterable, Optional, Tuple, Type, Union

from falcon import app_helpers as helpers
from falcon import constants
Expand All @@ -34,6 +36,7 @@
from falcon.response import Response
from falcon.response import ResponseOptions
import falcon.status_codes as status
from falcon.typing import ErrorHandler, ErrorSerializer, SinkPrefix
from falcon.util import deprecation
from falcon.util import misc
from falcon.util.misc import code_to_http_status
Expand Down Expand Up @@ -226,6 +229,9 @@ def process_response(self, req, resp, resource, req_succeeded)
'resp_options',
)

req_options: RequestOptions
resp_options: ResponseOptions

def __init__(
self,
media_type=constants.DEFAULT_MEDIA_TYPE,
Expand Down Expand Up @@ -285,7 +291,9 @@ def __init__(
self.add_error_handler(HTTPError, self._http_error_handler)
self.add_error_handler(HTTPStatus, self._http_status_handler)

def __call__(self, env, start_response): # noqa: C901
def __call__( # noqa: C901
self, env: dict, start_response: Callable
) -> Iterable[bytes]:
"""WSGI `app` method.
Makes instances of App callable from a WSGI server. May be used to
Expand All @@ -302,11 +310,11 @@ def __call__(self, env, start_response): # noqa: C901
"""
req = self._request_type(env, options=self.req_options)
resp = self._response_type(options=self.resp_options)
resource = None
responder = None
params = {}
resource: Optional[object] = None
responder: Optional[Callable] = None
params: dict = {}

dependent_mw_resp_stack = []
dependent_mw_resp_stack: list = []
mw_req_stack, mw_rsrc_stack, mw_resp_stack = self._middleware

req_succeeded = False
Expand Down Expand Up @@ -361,7 +369,7 @@ def __call__(self, env, start_response): # noqa: C901
break

if not resp.complete:
responder(req, resp, **params)
responder(req, resp, **params) # type: ignore

req_succeeded = True
except Exception as ex:
Expand Down Expand Up @@ -438,7 +446,7 @@ def __call__(self, env, start_response): # noqa: C901
def router_options(self):
return self._router.options

def add_middleware(self, middleware):
def add_middleware(self, middleware: object) -> None:
"""Add one or more additional middleware components.
Arguments:
Expand All @@ -465,7 +473,7 @@ def add_middleware(self, middleware):
independent_middleware=self._independent_middleware,
)

def add_route(self, uri_template, resource, **kwargs):
def add_route(self, uri_template: str, resource: object, **kwargs):
"""Associate a templatized URI path with a resource.
Falcon routes incoming requests to resources based on a set of
Expand Down Expand Up @@ -572,7 +580,11 @@ def on_get_bar(self, req, resp):
self._router.add_route(uri_template, resource, **kwargs)

def add_static_route(
self, prefix, directory, downloadable=False, fallback_filename=None
self,
prefix: str,
directory: Union[str, pathlib.Path],
downloadable: bool = False,
fallback_filename: Optional[str] = None,
):
"""Add a route to a directory of static files.
Expand Down Expand Up @@ -641,7 +653,7 @@ def add_static_route(
self._static_routes.insert(0, (sr, sr, False))
self._update_sink_and_static_routes()

def add_sink(self, sink, prefix=r'/'):
def add_sink(self, sink: Callable, prefix: SinkPrefix = r'/'):
"""Register a sink method for the App.
If no route matches a request, but the path in the requested URI
Expand Down Expand Up @@ -694,7 +706,11 @@ def add_sink(self, sink, prefix=r'/'):
self._sinks.insert(0, (prefix, sink, True))
self._update_sink_and_static_routes()

def add_error_handler(self, exception, handler=None):
def add_error_handler(
self,
exception: Union[Type[BaseException], Iterable[Type[BaseException]]],
handler: Optional[ErrorHandler] = None,
):
"""Register a handler for one or more exception types.
Error handlers may be registered for any exception type, including
Expand Down Expand Up @@ -794,7 +810,7 @@ def handler(req, resp, ex, params):

if handler is None:
try:
handler = exception.handle
handler = exception.handle # type: ignore
except AttributeError:
raise AttributeError(
'handler must either be specified '
Expand All @@ -814,8 +830,9 @@ def handler(req, resp, ex, params):
) or arg_names[1:3] in (('req', 'resp'), ('request', 'response')):
handler = wrap_old_handler(handler)

exception_tuple: tuple
try:
exception_tuple = tuple(exception)
exception_tuple = tuple(exception) # type: ignore
except TypeError:
exception_tuple = (exception,)

Expand All @@ -825,7 +842,7 @@ def handler(req, resp, ex, params):

self._error_handlers[exc] = handler

def set_error_serializer(self, serializer):
def set_error_serializer(self, serializer: ErrorSerializer):
"""Override the default serializer for instances of :class:`~.HTTPError`.
When a responder raises an instance of :class:`~.HTTPError`,
Expand Down Expand Up @@ -882,7 +899,9 @@ def _prepare_middleware(self, middleware=None, independent_middleware=False):
middleware=middleware, independent_middleware=independent_middleware
)

def _get_responder(self, req):
def _get_responder(
self, req: Request
) -> Tuple[Callable, dict, object, Optional[str]]:
"""Search routes for a matching responder.
Args:
Expand Down Expand Up @@ -953,7 +972,9 @@ def _get_responder(self, req):

return (responder, params, resource, uri_template)

def _compose_status_response(self, req, resp, http_status):
def _compose_status_response(
self, req: Request, resp: Response, http_status: HTTPStatus
) -> None:
"""Compose a response for the given HTTPStatus instance."""

# PERF(kgriffs): The code to set the status and headers is identical
Expand All @@ -968,7 +989,9 @@ def _compose_status_response(self, req, resp, http_status):
# it's acceptable to set resp.text to None (to indicate no body).
resp.text = http_status.text

def _compose_error_response(self, req, resp, error):
def _compose_error_response(
self, req: Request, resp: Response, error: HTTPError
) -> None:
"""Compose a response for the given HTTPError instance."""

resp.status = error.status
Expand Down

0 comments on commit 574a0d0

Please sign in to comment.