Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(typing): add type hints to hooks #2183

Merged
merged 69 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
96ecda4
feat: Type app helpers module
copalco Aug 16, 2023
00739d1
feat: Add typing to errors module
copalco Aug 16, 2023
b550754
feat: Add typings to forwarded module
copalco Aug 16, 2023
ccc5c7c
feat: Add typing to hooks
copalco Aug 18, 2023
0fd6d7a
feat: Add typing to falcon hooks
copalco Aug 18, 2023
4a7daf0
feat: Add typing to http_error module
copalco Aug 18, 2023
31e4e0d
feat: Extract RawHeaders and NormalizedHeaders to typing module
copalco Aug 18, 2023
763fc7a
feat: Extract status to typing module
copalco Aug 18, 2023
1d13cd0
feat: Add typing to http_status module
copalco Aug 18, 2023
32fbd7d
feat: Add typing to inspect module
copalco Aug 18, 2023
42abb3a
feat: Add typing to middleware module
copalco Aug 18, 2023
2e05a28
feat: Replace protocol with interface
copalco Aug 19, 2023
967d3e8
feat: Add typing to redirects
copalco Aug 19, 2023
0d1aa47
feat: Type vendor mimeparse
copalco Aug 20, 2023
3214966
Changed RawHeaders to not include None
copalco Aug 24, 2023
2c48bc6
Reformated imports
copalco Aug 24, 2023
0368deb
Test that interface raises not implemented
copalco Aug 24, 2023
4e1d1c5
Type algorithm int values as float
copalco Aug 24, 2023
3282355
Changed allowed methods to Iterable
copalco Aug 24, 2023
9855a97
Imported annotations in hooks
copalco Aug 24, 2023
8709da4
Change argnames type to list of strings
copalco Aug 24, 2023
0e680bd
Changed Dict to mutable mapping
copalco Aug 24, 2023
1ec1629
Fixed formatting
copalco Aug 24, 2023
a1c8a91
Remove unused imports
copalco Aug 24, 2023
917db59
Fix typing
copalco Aug 24, 2023
5f9984f
Replaced assert with cast
copalco Aug 24, 2023
edc739c
Fix blue
copalco Aug 24, 2023
ae03c3e
Type resource as object
copalco Aug 24, 2023
a66ab36
Fix style
copalco Aug 24, 2023
14e9d30
Revert "Type algorithm int values as float"
copalco Aug 29, 2023
51d0b43
Revert "feat: Type vendor mimeparse"
copalco Aug 29, 2023
73c42b9
Ignore vendore package
copalco Aug 30, 2023
4a5b2c5
Use async package instead of importing AsyncRequest and AsyncResponse…
copalco Aug 30, 2023
99f8bb2
Solve circular imports while typing
copalco Aug 30, 2023
ee2ff5e
Fix style
copalco Aug 30, 2023
6e02f9a
Changed inspect obj type to Any
copalco Aug 31, 2023
f7bd33c
Import annotations where missing
copalco Sep 4, 2023
7470dae
Replace Union with | where future annotations imported
copalco Sep 6, 2023
e44512e
Revert "Replace Union with | where future annotations imported"
copalco Sep 6, 2023
d5a87c5
Improve imports to avoid them inside functions
CaselIT Sep 9, 2023
0f9a0c3
Fix typo
copalco Oct 16, 2023
1d793d4
Rename Kwargs to HTTPErrorKeywordArgs
copalco Oct 16, 2023
a3b6aec
Import whole package insted of specific types
copalco Oct 16, 2023
ba4e748
Fix style
copalco Oct 17, 2023
077ed14
Replace Serializer and MediaHandler with protocol
copalco Oct 17, 2023
fbfc4b4
Add assertion reason message
copalco Oct 27, 2023
b5e704f
Fix import issue
copalco Oct 29, 2023
95ede1b
Fix import order
copalco Oct 30, 2023
a0a481c
Fix coverage issues
copalco Nov 5, 2023
b2b94d8
Add ResponderOrResource and Action types
copalco Nov 5, 2023
53df7c0
Improve responders typing
copalco Oct 30, 2023
db12db8
Merge branch 'master' into add_type_hints
vytas7 Apr 14, 2024
47cfb2e
Merge branch 'master' into add_type_hints
CaselIT Aug 12, 2024
7b2df8b
style: run ruff
CaselIT Aug 12, 2024
70eef75
typing: improve hooks
CaselIT Aug 12, 2024
0813f62
typing: more improvement to hooks, install typing-extensions on <3.8
CaselIT Aug 12, 2024
21a9d9c
style: run formatters
CaselIT Aug 12, 2024
95d7e9e
fix: correct typo and add todo note regarding improvements
CaselIT Aug 12, 2024
c50f425
docs: improve docs
CaselIT Aug 12, 2024
e3141c4
fix: use string to refer to type_checking symbols
CaselIT Aug 12, 2024
b3c5c28
test: fix import
CaselIT Aug 12, 2024
7341f96
chore: make python 3.7 happy
CaselIT Aug 12, 2024
3296944
chore: make coverage happy
CaselIT Aug 12, 2024
269155d
Merge branch 'master' into add_type_hints_2183
CaselIT Aug 20, 2024
3ce8b64
refactor: remove support for python 3.7
CaselIT Aug 20, 2024
55bf166
chore: apply review suggestions
CaselIT Aug 21, 2024
e995828
chore: additional ignore for coverage to better support typing
CaselIT Aug 21, 2024
51e742a
Merge branch 'master' into add_type_hints_2183
CaselIT Aug 21, 2024
efdf4dc
chore: coverage again..
CaselIT Aug 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions falcon/_typing_extensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys

if sys.version_info < (3, 8):
from typing_extensions import Protocol as Protocol
else:
from typing import Protocol as Protocol
233 changes: 148 additions & 85 deletions falcon/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,94 @@
from inspect import getmembers
from inspect import iscoroutinefunction
import re
import typing as t
from typing import (
Any,
Awaitable,
Callable,
cast,
Dict,
List,
Tuple,
TYPE_CHECKING,
TypeVar,
Union,
)

from falcon._typing_extensions import Protocol
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: # pragma: no cover
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


class SyncBeforeFn(Protocol):
def __call__(
self,
req: wsgi.Request,
resp: wsgi.Response,
resource: Resource,
params: Dict[str, Any],
*args: Any,
CaselIT marked this conversation as resolved.
Show resolved Hide resolved
**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 +157,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 +216,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 +247,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 +268,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 +340,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
Loading