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: type hooks with ParamSpec #2345

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions docs/_newsfragments/2343.breakingchange.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Removed ``is_async`` argument from :meth:`~falcon.media.validators.jsonschema.validate`
and the hooks :meth:`~falcon.before` and :meth:`~falcon.after` since it's
no longer needed.
Cython from 3.0 will correctly mark ``asnyc def`` as coroutine, making
this argument no longer useful.
169 changes: 43 additions & 126 deletions falcon/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
cast,
Dict,
List,
Protocol,
Tuple,
TYPE_CHECKING,
TypeVar,
Expand All @@ -38,6 +37,12 @@
from falcon.util.misc import get_argnames
from falcon.util.sync import _wrap_non_coroutine_unsafe

try:
from typing import Concatenate, ParamSpec
except ImportError: # pragma: no cover
from typing_extensions import Concatenate
from typing_extensions import ParamSpec # type: ignore[assignment]

if TYPE_CHECKING:
import falcon as wsgi
from falcon import asgi
Expand All @@ -46,62 +51,8 @@
from falcon.typing import Responder
from falcon.typing import ResponderMethod


# 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'])
_FN = ParamSpec('_FN')


_DECORABLE_METHOD_NAME = re.compile(
Expand All @@ -110,7 +61,18 @@ def __call__(


def before(
action: BeforeFn, *args: Any, is_async: bool = False, **kwargs: Any
action: Union[
Callable[
Concatenate[wsgi.Request, wsgi.Response, Resource, Dict[str, Any], _FN],
None,
],
Callable[
Concatenate[asgi.Request, asgi.Response, Resource, Dict[str, Any], _FN],
Awaitable[None],
],
],
*args: _FN.args,
**kwargs: _FN.kwargs,
) -> Callable[[_R], _R]:
"""Execute the given action function *before* the responder.

Expand Down Expand Up @@ -142,21 +104,6 @@ def do_something(req, resp, resource, params):
and *params* arguments.

Keyword Args:
is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
the decorated responder is a coroutine function (i.e., that it
is defined with ``async def``) or that it returns an awaitable
coroutine object.

Normally, when the function source is declared using ``async def``,
the resulting function object is flagged to indicate it returns a
coroutine when invoked, and this can be automatically detected.
However, it is possible to use a regular function to return an
awaitable coroutine object, in which case a hint is required to let
the framework know what to expect. Also, a hint is always required
when using a cythonized coroutine function, since Cython does not
flag them in a way that can be detected in advance, even when the
function is declared using ``async def``.

**kwargs: Any additional keyword arguments will be passed through to
*action*.
"""
Expand All @@ -168,25 +115,30 @@ def _before(responder_or_resource: _R) -> _R:
):
if _DECORABLE_METHOD_NAME.match(responder_name):
responder = cast('Responder', responder)
do_before_all = _wrap_with_before(
responder, action, args, kwargs, is_async
)
do_before_all = _wrap_with_before(responder, action, args, kwargs)

setattr(responder_or_resource, responder_name, do_before_all)

return cast(_R, responder_or_resource)

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

return cast(_R, do_before_one)

return _before


def after(
action: AfterFn, *args: Any, is_async: bool = False, **kwargs: Any
action: Union[
Callable[Concatenate[wsgi.Request, wsgi.Response, Resource, _FN], None],
Callable[
Concatenate[asgi.Request, asgi.Response, Resource, _FN], Awaitable[None]
],
],
*args: _FN.args,
**kwargs: _FN.kwargs,
) -> Callable[[_R], _R]:
"""Execute the given action function *after* the responder.

Expand All @@ -201,21 +153,6 @@ def after(
arguments.

Keyword Args:
is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
the decorated responder is a coroutine function (i.e., that it
is defined with ``async def``) or that it returns an awaitable
coroutine object.

Normally, when the function source is declared using ``async def``,
the resulting function object is flagged to indicate it returns a
coroutine when invoked, and this can be automatically detected.
However, it is possible to use a regular function to return an
awaitable coroutine object, in which case a hint is required to let
the framework know what to expect. Also, a hint is always required
when using a cythonized coroutine function, since Cython does not
flag them in a way that can be detected in advance, even when the
function is declared using ``async def``.

**kwargs: Any additional keyword arguments will be passed through to
*action*.
"""
Expand All @@ -227,17 +164,15 @@ def _after(responder_or_resource: _R) -> _R:
):
if _DECORABLE_METHOD_NAME.match(responder_name):
responder = cast('Responder', responder)
do_after_all = _wrap_with_after(
responder, action, args, kwargs, is_async
)
do_after_all = _wrap_with_after(responder, action, args, kwargs)

setattr(responder_or_resource, responder_name, do_after_all)

return cast(_R, responder_or_resource)

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

return cast(_R, do_after_one)

Expand All @@ -251,10 +186,9 @@ def _after(responder_or_resource: _R) -> _R:

def _wrap_with_after(
responder: Responder,
action: AfterFn,
action: Callable[..., Union[None, Awaitable[None]]],
action_args: Any,
action_kwargs: Any,
is_async: bool,
) -> Responder:
"""Execute the given action function after a responder method.

Expand All @@ -264,24 +198,16 @@ def _wrap_with_after(
method, taking the form ``func(req, resp, resource)``.
action_args: Additional positional arguments to pass to *action*.
action_kwargs: Additional keyword arguments to pass to *action*.
is_async: Set to ``True`` for cythonized responders that are
actually coroutine functions, since such responders can not
be auto-detected. A hint is also required for regular functions
that happen to return an awaitable coroutine object.
"""

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 = cast('AsyncAfterFn', _wrap_non_coroutine_unsafe(action))
else:
async_action = cast('AsyncAfterFn', action)
if iscoroutinefunction(responder):
async_action = cast(
Callable[..., Awaitable[None]], _wrap_non_coroutine_unsafe(action)
)
async_responder = cast('AsgiResponderMethod', responder)

@wraps(async_responder)
Expand All @@ -300,7 +226,7 @@ async def do_after(

do_after_responder = cast('AsgiResponderMethod', do_after)
else:
sync_action = cast('SyncAfterFn', action)
sync_action = cast(Callable[..., None], action)
sync_responder = cast('ResponderMethod', responder)

@wraps(sync_responder)
Expand All @@ -323,10 +249,9 @@ def do_after(

def _wrap_with_before(
responder: Responder,
action: BeforeFn,
action: Callable[..., Union[None, Awaitable[None]]],
action_args: Tuple[Any, ...],
action_kwargs: Dict[str, Any],
is_async: bool,
) -> Responder:
"""Execute the given action function before a responder method.

Expand All @@ -336,24 +261,16 @@ def _wrap_with_before(
method, taking the form ``func(req, resp, resource, params)``.
action_args: Additional positional arguments to pass to *action*.
action_kwargs: Additional keyword arguments to pass to *action*.
is_async: Set to ``True`` for cythonized responders that are
actually coroutine functions, since such responders can not
be auto-detected. A hint is also required for regular functions
that happen to return an awaitable coroutine object.
"""

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 = cast('AsyncBeforeFn', _wrap_non_coroutine_unsafe(action))
else:
async_action = cast('AsyncBeforeFn', action)
if iscoroutinefunction(responder):
async_action = cast(
Callable[..., Awaitable[None]], _wrap_non_coroutine_unsafe(action)
)
async_responder = cast('AsgiResponderMethod', responder)

@wraps(async_responder)
Expand All @@ -372,7 +289,7 @@ async def do_before(

do_before_responder = cast('AsgiResponderMethod', do_before)
else:
sync_action = cast('SyncBeforeFn', action)
sync_action = cast(Callable[..., None], action)
sync_responder = cast('ResponderMethod', responder)

@wraps(sync_responder)
Expand Down
31 changes: 2 additions & 29 deletions falcon/media/validators/jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@


def validate(
req_schema: Schema = None, resp_schema: Schema = None, is_async: bool = False
req_schema: Schema = None, resp_schema: Schema = None
) -> Callable[[ResponderMethod], ResponderMethod]:
"""Validate ``req.media`` using JSON Schema.

Expand Down Expand Up @@ -51,20 +51,6 @@ def validate(
resp_schema (dict): A dictionary that follows the JSON
Schema specification. The response will be validated against this
schema.
is_async (bool): Set to ``True`` for ASGI apps to provide a hint that
the decorated responder is a coroutine function (i.e., that it
is defined with ``async def``) or that it returns an awaitable
coroutine object.

Normally, when the function source is declared using ``async def``,
the resulting function object is flagged to indicate it returns a
coroutine when invoked, and this can be automatically detected.
However, it is possible to use a regular function to return an
awaitable coroutine object, in which case a hint is required to let
the framework know what to expect. Also, a hint is always required
when using a cythonized coroutine function, since Cython does not
flag them in a way that can be detected in advance, even when the
function is declared using ``async def``.

Example:

Expand Down Expand Up @@ -96,23 +82,10 @@ async def on_post(self, req, resp):

# -- snip --

.. tab-item:: ASGI (Cythonized App)

.. code:: python

from falcon.media.validators import jsonschema

# -- snip --

@jsonschema.validate(my_post_schema, is_async=True)
async def on_post(self, req, resp):

# -- snip --

"""

def decorator(func: ResponderMethod) -> ResponderMethod:
if iscoroutinefunction(func) or is_async:
if iscoroutinefunction(func):
return _validate_async(func, req_schema, resp_schema)

return _validate(func, req_schema, resp_schema)
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ requires = [
name = "falcon"
readme = {file = "README.rst", content-type = "text/x-rst"}
dynamic = ["version"]
dependencies = []
dependencies = [
"typing-extensions >= 4.2.0; python_version<'3.10'",
]
requires-python = ">=3.8"
description = "The ultra-reliable, fast ASGI+WSGI framework for building data plane APIs at scale."
authors = [
Expand Down
Loading