Skip to content

Commit

Permalink
feat(typing): add type hints to hooks (#2183)
Browse files Browse the repository at this point in the history
* feat: Type app helpers module

* feat: Add typing to errors module

* feat: Add typings to forwarded module

* feat: Add typing to hooks

* feat: Add typing to falcon hooks

* feat: Add typing to http_error module

* feat: Extract RawHeaders and NormalizedHeaders to typing module

* feat: Extract status to typing module

* feat: Add typing to http_status module

* feat: Add typing to inspect module

* feat: Add typing to middleware module

* feat: Replace protocol with interface

* feat: Add typing to redirects

* feat: Type vendor mimeparse

* Changed RawHeaders to not include None

* Reformated imports

* Test that interface raises not implemented

* Type algorithm int values as float

* Changed allowed methods to Iterable

* Imported annotations in hooks

* Change argnames type to list of strings

* Changed Dict to mutable mapping

* Fixed formatting

* Remove unused imports

* Fix typing

* Replaced assert with cast

* Fix blue

* Type resource as object

* Fix style

* Revert "Type algorithm int values as float"

This reverts commit ca1df71.

* Revert "feat: Type vendor mimeparse"

This reverts commit 11ca7ca.

* Ignore vendore package

* Use async package instead of importing AsyncRequest and AsyncResponse and aliasing them

* Solve circular imports while typing

* Fix style

* Changed inspect obj type to Any

* Import annotations where missing

* Replace Union with | where future annotations imported

* Revert "Replace Union with | where future annotations imported"

This reverts commit fd8b3be.

* Improve imports to avoid them inside functions

* Fix typo

* Rename Kwargs to HTTPErrorKeywordArgs

* Import whole package insted of specific types

* Fix style

* Replace Serializer and MediaHandler with protocol

* Add assertion reason message

* Fix import issue

* Fix import order

* Fix coverage issues

* Add ResponderOrResource and Action types

* Improve responders typing

* style: run ruff

* typing: improve hooks

* typing: more improvement to hooks, install typing-extensions on <3.8

* style: run formatters

* fix: correct typo and add todo note regarding improvements

* docs: improve docs

* fix: use string to refer to type_checking symbols

* test: fix import

* chore: make python 3.7 happy

* chore: make coverage happy

* refactor: remove support for python 3.7

* chore: apply review suggestions

* chore: additional ignore for coverage to better support typing

* chore: coverage again..

---------

Co-authored-by: Federico Caselli <[email protected]>
  • Loading branch information
copalco and CaselIT authored Aug 21, 2024
1 parent edcd5f6 commit e0f731a
Show file tree
Hide file tree
Showing 6 changed files with 278 additions and 124 deletions.
7 changes: 4 additions & 3 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ parallel = True

[report]
show_missing = True
exclude_lines =
# https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion
exclude_also =
if TYPE_CHECKING:
if not TYPE_CHECKING:
pragma: nocover
pragma: no cover
pragma: no py39,py310 cover
@overload
class .*\bProtocol\):
237 changes: 152 additions & 85 deletions falcon/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,98 @@
from inspect import getmembers
from inspect import iscoroutinefunction
import re
import typing as t
from typing import (
Any,
Awaitable,
Callable,
cast,
Dict,
List,
Protocol,
Tuple,
TYPE_CHECKING,
TypeVar,
Union,
)

from falcon.constants import COMBINED_METHODS
from falcon.util.misc import get_argnames
from falcon.util.sync import _wrap_non_coroutine_unsafe

if t.TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
import falcon as wsgi
from falcon import asgi
from falcon.typing import AsyncResponderMethod
from falcon.typing import Resource
from falcon.typing import Responder
from falcon.typing import SyncResponderMethod


# TODO: if is_async is removed these protocol would no longer be needed, since
# ParamSpec could be used together with Concatenate to use a simple Callable
# to type the before and after functions. This approach was prototyped in
# https://github.com/falconry/falcon/pull/2234
class SyncBeforeFn(Protocol):
def __call__(
self,
req: wsgi.Request,
resp: wsgi.Response,
resource: Resource,
params: Dict[str, Any],
*args: Any,
**kwargs: Any,
) -> None: ...


class AsyncBeforeFn(Protocol):
def __call__(
self,
req: asgi.Request,
resp: asgi.Response,
resource: Resource,
params: Dict[str, Any],
*args: Any,
**kwargs: Any,
) -> Awaitable[None]: ...


BeforeFn = Union[SyncBeforeFn, AsyncBeforeFn]


class SyncAfterFn(Protocol):
def __call__(
self,
req: wsgi.Request,
resp: wsgi.Response,
resource: Resource,
*args: Any,
**kwargs: Any,
) -> None: ...


class AsyncAfterFn(Protocol):
def __call__(
self,
req: asgi.Request,
resp: asgi.Response,
resource: Resource,
*args: Any,
**kwargs: Any,
) -> Awaitable[None]: ...


AfterFn = Union[SyncAfterFn, AsyncAfterFn]
_R = TypeVar('_R', bound=Union['Responder', 'Resource'])


_DECORABLE_METHOD_NAME = re.compile(
r'^on_({})(_\w+)?$'.format('|'.join(method.lower() for method in COMBINED_METHODS))
)

Resource = object
Responder = t.Callable
ResponderOrResource = t.Union[Responder, Resource]
Action = t.Callable


def before(
action: Action, *args: t.Any, is_async: bool = False, **kwargs: t.Any
) -> t.Callable[[ResponderOrResource], ResponderOrResource]:
action: BeforeFn, *args: Any, is_async: bool = False, **kwargs: Any
) -> Callable[[_R], _R]:
"""Execute the given action function *before* the responder.
The `params` argument that is passed to the hook
Expand Down Expand Up @@ -92,41 +161,33 @@ def do_something(req, resp, resource, params):
*action*.
"""

def _before(responder_or_resource: ResponderOrResource) -> ResponderOrResource:
def _before(responder_or_resource: _R) -> _R:
if isinstance(responder_or_resource, type):
resource = responder_or_resource

for responder_name, responder in getmembers(resource, callable):
for responder_name, responder in getmembers(
responder_or_resource, callable
):
if _DECORABLE_METHOD_NAME.match(responder_name):
# This pattern is necessary to capture the current value of
# responder in the do_before_all closure; otherwise, they
# will capture the same responder variable that is shared
# between iterations of the for loop, above.
responder = t.cast(Responder, responder)

def let(responder: Responder = responder) -> None:
do_before_all = _wrap_with_before(
responder, action, args, kwargs, is_async
)
responder = cast('Responder', responder)
do_before_all = _wrap_with_before(
responder, action, args, kwargs, is_async
)

setattr(resource, responder_name, do_before_all)
setattr(responder_or_resource, responder_name, do_before_all)

let()

return resource
return cast(_R, responder_or_resource)

else:
responder = t.cast(Responder, responder_or_resource)
responder = cast('Responder', responder_or_resource)
do_before_one = _wrap_with_before(responder, action, args, kwargs, is_async)

return do_before_one
return cast(_R, do_before_one)

return _before


def after(
action: Action, *args: t.Any, is_async: bool = False, **kwargs: t.Any
) -> t.Callable[[ResponderOrResource], ResponderOrResource]:
action: AfterFn, *args: Any, is_async: bool = False, **kwargs: Any
) -> Callable[[_R], _R]:
"""Execute the given action function *after* the responder.
Args:
Expand Down Expand Up @@ -159,30 +220,26 @@ def after(
*action*.
"""

def _after(responder_or_resource: ResponderOrResource) -> ResponderOrResource:
def _after(responder_or_resource: _R) -> _R:
if isinstance(responder_or_resource, type):
resource = t.cast(Resource, responder_or_resource)

for responder_name, responder in getmembers(resource, callable):
for responder_name, responder in getmembers(
responder_or_resource, callable
):
if _DECORABLE_METHOD_NAME.match(responder_name):
responder = t.cast(Responder, responder)

def let(responder: Responder = responder) -> None:
do_after_all = _wrap_with_after(
responder, action, args, kwargs, is_async
)
responder = cast('Responder', responder)
do_after_all = _wrap_with_after(
responder, action, args, kwargs, is_async
)

setattr(resource, responder_name, do_after_all)
setattr(responder_or_resource, responder_name, do_after_all)

let()

return resource
return cast(_R, responder_or_resource)

else:
responder = t.cast(Responder, responder_or_resource)
responder = cast('Responder', responder_or_resource)
do_after_one = _wrap_with_after(responder, action, args, kwargs, is_async)

return do_after_one
return cast(_R, do_after_one)

return _after

Expand All @@ -194,9 +251,9 @@ def let(responder: Responder = responder) -> None:

def _wrap_with_after(
responder: Responder,
action: Action,
action_args: t.Any,
action_kwargs: t.Any,
action: AfterFn,
action_args: Any,
action_kwargs: Any,
is_async: bool,
) -> Responder:
"""Execute the given action function after a responder method.
Expand All @@ -215,57 +272,62 @@ def _wrap_with_after(

responder_argnames = get_argnames(responder)
extra_argnames = responder_argnames[2:] # Skip req, resp
do_after_responder: Responder

if is_async or iscoroutinefunction(responder):
# NOTE(kgriffs): I manually verified that the implicit "else" branch
# is actually covered, but coverage isn't tracking it for
# some reason.
if not is_async: # pragma: nocover
async_action = _wrap_non_coroutine_unsafe(action)
async_action = cast('AsyncAfterFn', _wrap_non_coroutine_unsafe(action))
else:
async_action = action
async_action = cast('AsyncAfterFn', action)
async_responder = cast('AsyncResponderMethod', responder)

@wraps(responder)
@wraps(async_responder)
async def do_after(
self: ResponderOrResource,
self: Resource,
req: asgi.Request,
resp: asgi.Response,
*args: t.Any,
**kwargs: t.Any,
*args: Any,
**kwargs: Any,
) -> None:
if args:
_merge_responder_args(args, kwargs, extra_argnames)

await responder(self, req, resp, **kwargs)
assert async_action
await async_responder(self, req, resp, **kwargs)
await async_action(req, resp, self, *action_args, **action_kwargs)

do_after_responder = cast('AsyncResponderMethod', do_after)
else:
sync_action = cast('SyncAfterFn', action)
sync_responder = cast('SyncResponderMethod', responder)

@wraps(responder)
@wraps(sync_responder)
def do_after(
self: ResponderOrResource,
self: Resource,
req: wsgi.Request,
resp: wsgi.Response,
*args: t.Any,
**kwargs: t.Any,
*args: Any,
**kwargs: Any,
) -> None:
if args:
_merge_responder_args(args, kwargs, extra_argnames)

responder(self, req, resp, **kwargs)
action(req, resp, self, *action_args, **action_kwargs)
sync_responder(self, req, resp, **kwargs)
sync_action(req, resp, self, *action_args, **action_kwargs)

return do_after
do_after_responder = cast('SyncResponderMethod', do_after)
return do_after_responder


def _wrap_with_before(
responder: Responder,
action: Action,
action_args: t.Tuple[t.Any, ...],
action_kwargs: t.Dict[str, t.Any],
action: BeforeFn,
action_args: Tuple[Any, ...],
action_kwargs: Dict[str, Any],
is_async: bool,
) -> t.Union[t.Callable[..., t.Awaitable[None]], t.Callable[..., None]]:
) -> Responder:
"""Execute the given action function before a responder method.
Args:
Expand All @@ -282,52 +344,57 @@ def _wrap_with_before(

responder_argnames = get_argnames(responder)
extra_argnames = responder_argnames[2:] # Skip req, resp
do_before_responder: Responder

if is_async or iscoroutinefunction(responder):
# NOTE(kgriffs): I manually verified that the implicit "else" branch
# is actually covered, but coverage isn't tracking it for
# some reason.
if not is_async: # pragma: nocover
async_action = _wrap_non_coroutine_unsafe(action)
async_action = cast('AsyncBeforeFn', _wrap_non_coroutine_unsafe(action))
else:
async_action = action
async_action = cast('AsyncBeforeFn', action)
async_responder = cast('AsyncResponderMethod', responder)

@wraps(responder)
@wraps(async_responder)
async def do_before(
self: ResponderOrResource,
self: Resource,
req: asgi.Request,
resp: asgi.Response,
*args: t.Any,
**kwargs: t.Any,
*args: Any,
**kwargs: Any,
) -> None:
if args:
_merge_responder_args(args, kwargs, extra_argnames)

assert async_action
await async_action(req, resp, self, kwargs, *action_args, **action_kwargs)
await responder(self, req, resp, **kwargs)
await async_responder(self, req, resp, **kwargs)

do_before_responder = cast('AsyncResponderMethod', do_before)
else:
sync_action = cast('SyncBeforeFn', action)
sync_responder = cast('SyncResponderMethod', responder)

@wraps(responder)
@wraps(sync_responder)
def do_before(
self: ResponderOrResource,
self: Resource,
req: wsgi.Request,
resp: wsgi.Response,
*args: t.Any,
**kwargs: t.Any,
*args: Any,
**kwargs: Any,
) -> None:
if args:
_merge_responder_args(args, kwargs, extra_argnames)

action(req, resp, self, kwargs, *action_args, **action_kwargs)
responder(self, req, resp, **kwargs)
sync_action(req, resp, self, kwargs, *action_args, **action_kwargs)
sync_responder(self, req, resp, **kwargs)

return do_before
do_before_responder = cast('SyncResponderMethod', do_before)
return do_before_responder


def _merge_responder_args(
args: t.Tuple[t.Any, ...], kwargs: t.Dict[str, t.Any], argnames: t.List[str]
args: Tuple[Any, ...], kwargs: Dict[str, Any], argnames: List[str]
) -> None:
"""Merge responder args into kwargs.
Expand Down
Loading

0 comments on commit e0f731a

Please sign in to comment.