From d0b2358d28d0ee8bf5e4224ff9b3ebf36b93ac5c Mon Sep 17 00:00:00 2001 From: Nick Zaccardi Date: Fri, 21 Jun 2024 22:42:14 -0500 Subject: [PATCH] Add boundary types for before hooks ** BREAKING CHANGE ** This adds typing information for the before hook as a proof of concept. I used ParamSpec as a means to introspect the hooks arguments to type check the *args and **kwargs arguments. A problem with using ParamSpec is that additional arguments to the before decorator become impossible (so far as I can see) to specify a type. is_async, in needs to come after the positional args, but that is not allowed with ParamSpec. The solution to this is to move is_async to be consumed through **kwargs and no longer a special keyword argument. I don't think this is actually a breaking change, but because it changes a signature it might be worth marking it as such. --- falcon/__init__.py | 106 ++++++++++++++++++++++----------------------- falcon/hooks.py | 21 +++++++-- pyproject.toml | 1 + 3 files changed, 71 insertions(+), 57 deletions(-) diff --git a/falcon/__init__.py b/falcon/__init__.py index 5b0e186e1..af7b9e128 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -101,57 +101,57 @@ __all__ = ( - "API", - "App", - "after", - "before", - "HTTPError", - "HTTPStatus", - "CORSMiddleware", - "HTTPFound", - "HTTPMovedPermanently", - "HTTPPermanentRedirect", - "HTTPSeeOther", - "HTTPTemporaryRedirect", - "Forwarded", - "Request", - "RequestOptions", - "Response", - "ResponseOptions", - "BoundedStream", - "async_to_sync", - "BufferedReader", - "CaseInsensitiveDict", - "code_to_http_status", - "Context", - "create_task", - "deprecated", - "dt_to_http", - "ETag", - "get_argnames", - "get_bound_method", - "get_http_status", - "get_running_loop", - "http_cookies", - "http_date_to_dt", - "http_now", - "http_status_to_code", - "IS_64_BITS", - "is_python_func", - "misc", - "parse_header", - "reader", - "runs_sync", - "secure_filename", - "structures", - "sync", - "sync_to_async", - "sys", - "time", - "TimezoneGMT", - "to_query_str", - "uri", - "wrap_sync_to_async", - "wrap_sync_to_async_unsafe", - "__version__", + 'API', + 'App', + 'after', + 'before', + 'HTTPError', + 'HTTPStatus', + 'CORSMiddleware', + 'HTTPFound', + 'HTTPMovedPermanently', + 'HTTPPermanentRedirect', + 'HTTPSeeOther', + 'HTTPTemporaryRedirect', + 'Forwarded', + 'Request', + 'RequestOptions', + 'Response', + 'ResponseOptions', + 'BoundedStream', + 'async_to_sync', + 'BufferedReader', + 'CaseInsensitiveDict', + 'code_to_http_status', + 'Context', + 'create_task', + 'deprecated', + 'dt_to_http', + 'ETag', + 'get_argnames', + 'get_bound_method', + 'get_http_status', + 'get_running_loop', + 'http_cookies', + 'http_date_to_dt', + 'http_now', + 'http_status_to_code', + 'IS_64_BITS', + 'is_python_func', + 'misc', + 'parse_header', + 'reader', + 'runs_sync', + 'secure_filename', + 'structures', + 'sync', + 'sync_to_async', + 'sys', + 'time', + 'TimezoneGMT', + 'to_query_str', + 'uri', + 'wrap_sync_to_async', + 'wrap_sync_to_async_unsafe', + '__version__', ) diff --git a/falcon/hooks.py b/falcon/hooks.py index 4172e9da6..71d15058f 100644 --- a/falcon/hooks.py +++ b/falcon/hooks.py @@ -15,21 +15,32 @@ """Hook decorators.""" from functools import wraps -from inspect import getmembers -from inspect import iscoroutinefunction +from inspect import getmembers, iscoroutinefunction import re +import typing_extensions as te + from falcon.constants import COMBINED_METHODS +from falcon.request import Request +from falcon.response import Response from falcon.util.misc import get_argnames from falcon.util.sync import _wrap_non_coroutine_unsafe - _DECORABLE_METHOD_NAME = re.compile( r'^on_({})(_\w+)?$'.format('|'.join(method.lower() for method in COMBINED_METHODS)) ) -def before(action, *args, is_async=False, **kwargs): +P = te.ParamSpec('P') +R = te.TypeVar('R', bound=Request) +S = te.TypeVar('S', bound=Response) + + +def before( + action: te.Callable[te.Concatenate[R, S, te.Any, te.Dict[str, te.Any], P], None], + *args: P.args, + **kwargs: P.kwargs, +) -> te.Callable[[te.Any], te.Any]: """Execute the given action function *before* the responder. The `params` argument that is passed to the hook @@ -79,6 +90,8 @@ def do_something(req, resp, resource, params): *action*. """ + is_async = kwargs.get('is_async', False) + def _before(responder_or_resource): if isinstance(responder_or_resource, type): resource = responder_or_resource diff --git a/pyproject.toml b/pyproject.toml index 73fbcea1e..425cb1bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ "setuptools>=47", "wheel>=0.34", "cython>=0.29.21; python_implementation == 'CPython'", # Skip cython when using pypy + "typing-extensions>=4" ] [tool.mypy]