diff --git a/docs/api/util.rst b/docs/api/util.rst index fa8437d78..088f19a15 100644 --- a/docs/api/util.rst +++ b/docs/api/util.rst @@ -15,7 +15,7 @@ Miscellaneous .. automodule:: falcon :members: deprecated, http_now, dt_to_http, http_date_to_dt, - to_query_str, get_http_status, get_bound_method + to_query_str, get_http_status, get_bound_method, is_python_func .. autoclass:: falcon.TimezoneGMT :members: diff --git a/falcon/app_helpers.py b/falcon/app_helpers.py index f6b4ce2a6..02015f234 100644 --- a/falcon/app_helpers.py +++ b/falcon/app_helpers.py @@ -81,7 +81,13 @@ def prepare_middleware(middleware, independent_middleware=False, asgi=False): ) for m in (process_request, process_resource, process_response): - if m and not iscoroutinefunction(m): + # NOTE(kgriffs): iscoroutinefunction() always returns False + # for cythonized functions. + # + # https://github.com/cython/cython/issues/2273 + # https://bugs.python.org/issue38225 + # + if m and not iscoroutinefunction(m) and util.is_python_func(m): msg = ( '{} must be implemented as an awaitable coroutine. If ' 'you would like to retain compatibility ' diff --git a/falcon/asgi/app.py b/falcon/asgi/app.py index 5d28621f2..fb11f0955 100644 --- a/falcon/asgi/app.py +++ b/falcon/asgi/app.py @@ -23,7 +23,7 @@ from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus import falcon.routing -from falcon.util.misc import http_status_to_code +from falcon.util.misc import http_status_to_code, is_python_func from falcon.util.sync import _wrap_non_coroutine_unsafe, get_loop from .request import Request from .response import Response @@ -653,7 +653,13 @@ async def handle(req, resp, ex, params): handler = _wrap_non_coroutine_unsafe(handler) - if not iscoroutinefunction(handler): + # NOTE(kgriffs): iscoroutinefunction() always returns False + # for cythonized functions. + # + # https://github.com/cython/cython/issues/2273 + # https://bugs.python.org/issue38225 + # + if not iscoroutinefunction(handler) and is_python_func(handler): raise CompatibilityError( 'The handler must be an awaitable coroutine function in order ' 'to be used safely with an ASGI app.' @@ -681,8 +687,8 @@ def _schedule_callbacks(self, resp): loop = get_loop() - for cb in callbacks: - if iscoroutinefunction(cb): + for cb, is_async in callbacks: + if is_async: loop.create_task(cb()) else: loop.run_in_executor(None, cb) diff --git a/falcon/asgi/request.py b/falcon/asgi/request.py index 55c46d60f..2b36bee7d 100644 --- a/falcon/asgi/request.py +++ b/falcon/asgi/request.py @@ -73,7 +73,7 @@ class Request(falcon.request.Request): __slots__ = [ '_asgi_headers', - '_asgi_server_cached' + '_asgi_server_cached', '_receive', '_stream', 'scope', diff --git a/falcon/asgi/response.py b/falcon/asgi/response.py index 65fa96c5a..253faccdd 100644 --- a/falcon/asgi/response.py +++ b/falcon/asgi/response.py @@ -15,10 +15,11 @@ """ASGI Response class.""" from asyncio.coroutines import CoroWrapper -from inspect import iscoroutine +from inspect import iscoroutine, iscoroutinefunction from falcon import _UNSET import falcon.response +from falcon.util.misc import is_python_func __all__ = ['Response'] @@ -151,20 +152,16 @@ async def render_body(self): return data def schedule(self, callback): - """Schedules a callback to run soon after sending the HTTP response. + """Schedule an async callback to run soon after sending the HTTP response. - This method can be used to execute a background job after the - response has been returned to the client. + This method can be used to execute a background job after the response + has been returned to the client. - If the callback is an async coroutine function, it will be scheduled - to run on the event loop as soon as possible. Alternatively, if a - synchronous callable is passed, it will be run on the event loop's - default ``Executor`` (which can be overridden via - :py:meth:`asyncio.AbstractEventLoop.set_default_executor`). + The callback is assumed to be an async coroutine function. It will be + scheduled to run on the event loop as soon as possible. The callback will be invoked without arguments. Use - :py:meth`functools.partial` to pass arguments to the callback - as needed. + :py:meth:`functools.partial` to pass arguments to the callback as needed. Note: If an unhandled exception is raised while processing the request, @@ -180,6 +177,58 @@ def schedule(self, callback): must use async libraries or delegate to an Executor pool to avoid blocking the processing of subsequent requests. + Args: + callback(object): An async coroutine function. The callback will be + invoked without arguments. + """ + + # NOTE(kgriffs): We also have to do the CoroWrapper check because + # iscoroutine is less reliable under Python 3.6. + if not iscoroutinefunction(callback): + if iscoroutine(callback) or isinstance(callback, CoroWrapper): + raise TypeError( + 'The callback object appears to ' + 'be a coroutine, rather than a coroutine function. Please ' + 'pass the function itself, rather than the result obtained ' + 'by calling the function. ' + ) + elif is_python_func(callback): # pragma: nocover + raise TypeError('The callback must be a coroutine function.') + + # NOTE(kgriffs): The implicit "else" branch is actually covered + # by tests running in a Cython environment, but we can't + # detect it with the coverage tool. + + rc = (callback, True) + + if not self._registered_callbacks: + self._registered_callbacks = [rc] + else: + self._registered_callbacks.append(rc) + + def schedule_sync(self, callback): + """Schedule a synchronous callback to run soon after sending the HTTP response. + + This method can be used to execute a background job after the + response has been returned to the client. + + The callback is assumed to be a synchronous (non-coroutine) function. + It will be scheduled on the event loop's default ``Executor`` (which + can be overridden via + :py:meth:`asyncio.AbstractEventLoop.set_default_executor`). + + The callback will be invoked without arguments. Use + :py:meth:`functools.partial` to pass arguments to the callback + as needed. + + Note: + If an unhandled exception is raised while processing the request, + the callback will not be scheduled to run. + + Note: + When an SSE emitter has been set on the response, the callback will + be scheduled before the first call to the emitter. + Warning: Synchronous callables run on the event loop's default ``Executor``, which uses an instance of ``ThreadPoolExecutor`` unless @@ -195,20 +244,12 @@ def schedule(self, callback): callable. The callback will be called without arguments. """ - # NOTE(kgriffs): We also have to do the CoroWrapper check because - # iscoroutine is less reliable under Python 3.6. - if iscoroutine(callback) or isinstance(callback, CoroWrapper): - raise TypeError( - 'The callback object appears to ' - 'be a coroutine, rather than a coroutine function. Please ' - 'pass the function itself, rather than the result obtained ' - 'by calling the function. ' - ) + rc = (callback, False) if not self._registered_callbacks: - self._registered_callbacks = [callback] + self._registered_callbacks = [rc] else: - self._registered_callbacks.append(callback) + self._registered_callbacks.append(rc) def set_stream(self, stream, content_length): """Convenience method for setting both `stream` and `content_length`. diff --git a/falcon/hooks.py b/falcon/hooks.py index e3f5168ab..24189ff10 100644 --- a/falcon/hooks.py +++ b/falcon/hooks.py @@ -27,7 +27,7 @@ '|'.join(method.lower() for method in COMBINED_METHODS))) -def before(action, *args, **kwargs): +def before(action, *args, is_async=False, **kwargs): """Decorator to execute the given action function *before* the responder. The `params` argument that is passed to the hook @@ -57,6 +57,22 @@ def do_something(req, resp, resource, params): order given, immediately following the *req*, *resp*, *resource*, 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*. """ @@ -72,7 +88,13 @@ def _before(responder_or_resource): # will capture the same responder variable that is shared # between iterations of the for loop, above. def let(responder=responder): - do_before_all = _wrap_with_before(responder, action, args, kwargs) + do_before_all = _wrap_with_before( + responder, + action, + args, + kwargs, + is_async + ) setattr(resource, responder_name, do_before_all) @@ -82,14 +104,14 @@ def let(responder=responder): else: responder = responder_or_resource - do_before_one = _wrap_with_before(responder, action, args, kwargs) + do_before_one = _wrap_with_before(responder, action, args, kwargs, is_async) return do_before_one return _before -def after(action, *args, **kwargs): +def after(action, *args, is_async=False, **kwargs): """Decorator to execute the given action function *after* the responder. Args: @@ -102,6 +124,22 @@ def after(action, *args, **kwargs): order given, immediately following the *req*, *resp*, *resource*, 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*. """ @@ -113,7 +151,13 @@ def _after(responder_or_resource): for responder_name, responder in getmembers(resource, callable): if _DECORABLE_METHOD_NAME.match(responder_name): def let(responder=responder): - do_after_all = _wrap_with_after(responder, action, args, kwargs) + do_after_all = _wrap_with_after( + responder, + action, + args, + kwargs, + is_async + ) setattr(resource, responder_name, do_after_all) @@ -123,7 +167,7 @@ def let(responder=responder): else: responder = responder_or_resource - do_after_one = _wrap_with_after(responder, action, args, kwargs) + do_after_one = _wrap_with_after(responder, action, args, kwargs, is_async) return do_after_one @@ -135,7 +179,7 @@ def let(responder=responder): # ----------------------------------------------------------------------------- -def _wrap_with_after(responder, action, action_args, action_kwargs): +def _wrap_with_after(responder, action, action_args, action_kwargs, is_async): """Execute the given action function after a responder method. Args: @@ -144,13 +188,21 @@ def _wrap_with_after(responder, action, action_args, action_kwargs): method, taking the form ``func(req, resp, resource)``. action_args: Additional positional agruments 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 - if iscoroutinefunction(responder): - action = _wrap_non_coroutine_unsafe(action) + 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 + action = _wrap_non_coroutine_unsafe(action) @wraps(responder) async def do_after(self, req, resp, *args, **kwargs): @@ -171,7 +223,7 @@ def do_after(self, req, resp, *args, **kwargs): return do_after -def _wrap_with_before(responder, action, action_args, action_kwargs): +def _wrap_with_before(responder, action, action_args, action_kwargs, is_async): """Execute the given action function before a responder method. Args: @@ -180,13 +232,21 @@ def _wrap_with_before(responder, action, action_args, action_kwargs): method, taking the form ``func(req, resp, resource, params)``. action_args: Additional positional agruments 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 - if iscoroutinefunction(responder): - action = _wrap_non_coroutine_unsafe(action) + 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 + action = _wrap_non_coroutine_unsafe(action) @wraps(responder) async def do_before(self, req, resp, *args, **kwargs): diff --git a/falcon/media/validators/jsonschema.py b/falcon/media/validators/jsonschema.py index 347f42c3d..62db1cdcc 100644 --- a/falcon/media/validators/jsonschema.py +++ b/falcon/media/validators/jsonschema.py @@ -9,7 +9,7 @@ pass -def validate(req_schema=None, resp_schema=None): +def validate(req_schema=None, resp_schema=None, is_async=False): """Decorator for validating ``req.media`` using JSON Schema. This decorator provides standard JSON Schema validation via the @@ -24,13 +24,27 @@ def validate(req_schema=None, resp_schema=None): See `json-schema.org `_ for more information on defining a compatible dictionary. - Args: - req_schema (dict, optional): A dictionary that follows the JSON + Keyword Args: + req_schema (dict): A dictionary that follows the JSON Schema specification. The request will be validated against this schema. - resp_schema (dict, optional): A dictionary that follows the JSON + 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: .. code:: python @@ -47,7 +61,7 @@ def on_post(self, req, resp): """ def decorator(func): - if iscoroutinefunction(func): + if iscoroutinefunction(func) or is_async: return _validate_async(func, req_schema, resp_schema) return _validate(func, req_schema, resp_schema) diff --git a/falcon/routing/compiled.py b/falcon/routing/compiled.py index 14497d6fe..96ebbfae3 100644 --- a/falcon/routing/compiled.py +++ b/falcon/routing/compiled.py @@ -22,6 +22,7 @@ from falcon.routing import converters from falcon.routing.util import map_http_methods, set_default_responders +from falcon.util.misc import is_python_func from falcon.util.sync import _should_wrap_non_coroutines, wrap_sync_to_async @@ -246,8 +247,7 @@ def _require_coroutine_responders(self, method_map): # operations that need to be explicitly made non-blocking # by the developer; raising an error helps highlight this # issue. - - if not iscoroutinefunction(responder): + if not iscoroutinefunction(responder) and is_python_func(responder): if _should_wrap_non_coroutines(): def let(responder=responder): method_map[method] = wrap_sync_to_async(responder) diff --git a/falcon/testing/client.py b/falcon/testing/client.py index 2722a3697..1475ff6f6 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -211,7 +211,7 @@ def headers(self) -> CaseInsensitiveDict: # CaseInsensitiveDict to inherit from a generic MutableMapping # type. This might be resolved in the future by moving # the CaseInsensitiveDict implementation to the falcon.testing - # module so that it is no longer Cythonized. + # module so that it is no longer cythonized. return self._headers @property diff --git a/falcon/util/misc.py b/falcon/util/misc.py index 4cd3b2886..d9962a867 100644 --- a/falcon/util/misc.py +++ b/falcon/util/misc.py @@ -35,6 +35,7 @@ from falcon import status_codes __all__ = ( + 'is_python_func', 'deprecated', 'http_now', 'dt_to_http', @@ -77,6 +78,30 @@ def decorator(func): _lru_cache_safe = functools.lru_cache +def is_python_func(func): + """Determines if a function or method uses a standard Python type. + + This helper can be used to check a function or method to determine if it + uses a standard Python type, as opposed to an implementation-specific + native extension type. + + For example, because Cython functions are not standard Python functions, + ``is_python_func(f)`` will return ``False`` when f is a reference to a + cythonized function or method. + + Args: + func: The function object to check. + Returns: + bool: ``True`` if the function or method uses a standard Python + type; ``False`` otherwise. + + """ + if inspect.ismethod(func): + func = func.__func__ + + return inspect.isfunction(func) + + # NOTE(kgriffs): We don't want our deprecations to be ignored by default, # so create our own type. # diff --git a/setup.py b/setup.py index 6aef39f66..9a88ccb8e 100644 --- a/setup.py +++ b/setup.py @@ -28,14 +28,6 @@ from Cython.Distutils import build_ext CYTHON = True except ImportError: - # TODO(kgriffs): pip now ignores all output, so the user - # may not see this message. See also: - # - # https://github.com/pypa/pip/issues/2732 - # - print('\nNOTE: Cython not installed. ' - 'Falcon will still work fine, but may run ' - 'a bit slower.\n') CYTHON = False if CYTHON: @@ -61,13 +53,17 @@ def list_modules(dirname, pattern): modules_to_exclude = [ # NOTE(kgriffs): Cython does not handle dynamically-created async - # methods correctly, so we do not cythonize the following modules. + # methods correctly. + # NOTE(vytas,kgriffs): We have to also avoid cythonizing several + # other functions that might make it so that the framework + # can not recognize them as coroutine functions. + # + # See also: + # + # * https://github.com/cython/cython/issues/2273 + # * https://bugs.python.org/issue38225 + # 'falcon.hooks', - # NOTE(vytas): Middleware classes cannot be cythonized until - # asyncio.iscoroutinefunction recognizes cythonized coroutines: - # * https://github.com/cython/cython/issues/2273 - # * https://bugs.python.org/issue38225 - 'falcon.middlewares', 'falcon.responders', 'falcon.util.sync', ] diff --git a/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py index cf8a480b7..7a76a6b41 100644 --- a/tests/asgi/_asgi_test_app.py +++ b/tests/asgi/_asgi_test_app.py @@ -28,9 +28,9 @@ def background_job_sync(): self._counter['backround:things:on_post'] += 1000 resp.schedule(background_job_async) - resp.schedule(background_job_sync) + resp.schedule_sync(background_job_sync) resp.schedule(background_job_async) - resp.schedule(background_job_sync) + resp.schedule_sync(background_job_sync) async def on_put(self, req, resp): # NOTE(kgriffs): Test that reading past the end does diff --git a/tests/asgi/_cythonized.pyx b/tests/asgi/_cythonized.pyx new file mode 100644 index 000000000..5624ba611 --- /dev/null +++ b/tests/asgi/_cythonized.pyx @@ -0,0 +1,117 @@ +# cython: language_level=3 + +from collections import Counter +import time + +import falcon +from falcon.media.validators.jsonschema import validate + + +_MESSAGE_SCHEMA = { + 'definitions': {}, + '$schema': 'http://json-schema.org/draft-07/schema#', + '$id': 'http://example.com/root.json', + 'type': 'object', + 'title': 'The Root Schema', + 'required': ['message'], + 'properties': { + 'message': { + '$id': '#/properties/message', + 'type': 'string', + 'title': 'The Message Schema', + 'default': '', + 'examples': ['hello world'], + 'pattern': '^(.*)$' + } + } +} + + +def nop_method(self): + pass + + +async def nop_method_async(self): + pass + + +class NOPClass: + def nop_method(self): + pass + + async def nop_method_async(self): + pass + + +class TestResourceWithValidation: + @validate(resp_schema=_MESSAGE_SCHEMA, is_async=True) + async def on_get(self, req, resp): + resp.media = { + 'message': 'hello world' + } + + +class TestResourceWithValidationNoHint: + @validate(resp_schema=_MESSAGE_SCHEMA) + async def on_get(self, req, resp): + resp.media = { + 'message': 'hello world' + } + + +class TestResourceWithScheduledJobs: + def __init__(self): + self.counter = Counter() + + async def on_get(self, req, resp): + async def background_job_async(): + self.counter['backround:on_get:async'] += 1 + + def background_job_sync(): + self.counter['backround:on_get:sync'] += 20 + + resp.schedule(background_job_async) + resp.schedule_sync(background_job_sync) + resp.schedule(background_job_async) + resp.schedule_sync(background_job_sync) + + +class TestResourceWithScheduledJobsAsyncRequired: + def __init__(self): + self.counter = Counter() + + async def on_get(self, req, resp): + def background_job_sync(): + pass + + # NOTE(kgriffs): This will fail later since we can't detect + # up front that it isn't a coroutine function. + resp.schedule(background_job_sync) + + +async def my_before_hook(req, resp, resource, params): + req.context.before = 42 + + +async def my_after_hook(req, resp, resource): + resp.set_header('X-Answer', '42') + resp.media = {'answer': req.context.before} + + +class TestResourceWithHooks: + @falcon.before(my_before_hook, is_async=True) + @falcon.after(my_after_hook, is_async=True) + async def on_get(self, req, resp): + pass + + +class TestResourceWithHooksNoHintBefore: + @falcon.before(my_before_hook) + async def on_get(self, req, resp): + pass + + +class TestResourceWithHooksNoHintBefore: + @falcon.after(my_before_hook) + async def on_get(self, req, resp): + pass diff --git a/tests/asgi/test_cythonized_asgi.py b/tests/asgi/test_cythonized_asgi.py new file mode 100644 index 000000000..d1758d51c --- /dev/null +++ b/tests/asgi/test_cythonized_asgi.py @@ -0,0 +1,127 @@ +import sys +import time + +import pytest + +import falcon +from falcon import testing +import falcon.asgi +from falcon.util import is_python_func +try: + import pyximport + pyximport.install() +except ImportError: + pyximport = None + +# NOTE(kgriffs): We do this here rather than inside the try block above, +# so that we don't mask errors importing _cythonized itself. +if pyximport: + from . import _cythonized # type: ignore + _CYTHON_FUNC_TEST_TYPES = [ + _cythonized.nop_method, + _cythonized.nop_method_async, + _cythonized.NOPClass.nop_method, + _cythonized.NOPClass.nop_method_async, + _cythonized.NOPClass().nop_method, + _cythonized.NOPClass().nop_method_async, + ] +else: + _CYTHON_FUNC_TEST_TYPES = [] + +from _util import disable_asgi_non_coroutine_wrapping # NOQA + + +@pytest.fixture +def client(): + return testing.TestClient(falcon.asgi.App()) + + +def nop_method(self): + pass + + +async def nop_method_async(self): + pass + + +class NOPClass: + def nop_method(self): + pass + + async def nop_method_async(self): + pass + + +@pytest.mark.skipif(not pyximport, reason='Cython not installed') +@pytest.mark.parametrize('func', _CYTHON_FUNC_TEST_TYPES) +def test_is_cython_func(func): + assert not is_python_func(func) + + +@pytest.mark.parametrize('func', [ + nop_method, + nop_method_async, + NOPClass.nop_method, + NOPClass.nop_method_async, + NOPClass().nop_method, + NOPClass().nop_method_async, +]) +def test_not_cython_func(func): + assert is_python_func(func) + + +@pytest.mark.skipif(not pyximport, reason='Cython not installed') +def test_jsonchema_validator(client): + with disable_asgi_non_coroutine_wrapping(): + client.app.add_route('/', _cythonized.TestResourceWithValidation()) + + with pytest.raises(TypeError): + client.app.add_route('/wowsuchfail', _cythonized.TestResourceWithValidationNoHint()) + + client.simulate_get() + + +@pytest.mark.skipif(not pyximport, reason='Cython not installed') +def test_scheduled_jobs(client): + resource = _cythonized.TestResourceWithScheduledJobs() + client.app.add_route('/', resource) + + client.simulate_get() + time.sleep(0.5) + assert resource.counter['backround:on_get:async'] == 2 + assert resource.counter['backround:on_get:sync'] == 40 + + +@pytest.mark.skipif(not pyximport, reason='Cython not installed') +@pytest.mark.skipif( + sys.version_info < (3, 7), + reason=( + 'CPython 3.6 does not complain when you try to call loop.create_task() ' + 'with the wrong type.' + ) +) +def test_scheduled_jobs_type_error(client): + client.app.add_route('/wowsuchfail', _cythonized.TestResourceWithScheduledJobsAsyncRequired()) + + # NOTE(kgriffs): Normally an unhandled exception is translated to a + # 500 response, but since jobs aren't supposed to be scheduled until + # we are done sending the response, we treat this as a special case + # and allow the error to propagate out of the server. Masking this kind + # of error would make it especially hard to debug in any case (it will + # be hard enough as it is for the app developer). + with pytest.raises(TypeError): + client.simulate_get('/wowsuchfail') + + +@pytest.mark.skipif(not pyximport, reason='Cython not installed') +def test_hooks(client): + with disable_asgi_non_coroutine_wrapping(): + with pytest.raises(TypeError): + client.app.add_route('/', _cythonized.TestResourceWithHooksNoHintBefore()) + client.app.add_route('/', _cythonized.TestResourceWithHooksNoHintAfter()) + + client.app.add_route('/', _cythonized.TestResourceWithHooks()) + + result = client.simulate_get() + assert result.headers['x-answer'] == '42' + assert result.json == {'answer': 42} diff --git a/tests/asgi/test_scheduled_callbacks.py b/tests/asgi/test_scheduled_callbacks.py index 19c94f6b5..c9a5d63d6 100644 --- a/tests/asgi/test_scheduled_callbacks.py +++ b/tests/asgi/test_scheduled_callbacks.py @@ -19,10 +19,13 @@ async def background_job_async(): def background_job_sync(): self.counter['backround:on_get:sync'] += 20 + with pytest.raises(TypeError): + resp.schedule(background_job_sync) + + resp.schedule_sync(background_job_sync) resp.schedule(background_job_async) - resp.schedule(background_job_sync) + resp.schedule_sync(background_job_sync) resp.schedule(background_job_async) - resp.schedule(background_job_sync) async def on_post(self, req, resp): async def background_job_async(): @@ -33,8 +36,8 @@ def background_job_sync(): resp.schedule(background_job_async) resp.schedule(background_job_async) - resp.schedule(background_job_sync) - resp.schedule(background_job_sync) + resp.schedule_sync(background_job_sync) + resp.schedule_sync(background_job_sync) async def on_put(self, req, resp): async def background_job_async(): diff --git a/tests/test_after_hooks.py b/tests/test_after_hooks.py index 675c93317..090504794 100644 --- a/tests/test_after_hooks.py +++ b/tests/test_after_hooks.py @@ -132,12 +132,12 @@ def on_post(self, req, resp): class WrappedRespondersResourceAsync: @falcon.after(serialize_body_async) - @falcon.after(validate_output) + @falcon.after(validate_output, is_async=False) async def on_get(self, req, resp): self.req = req self.resp = resp - @falcon.after(serialize_body_async) + @falcon.after(serialize_body_async, is_async=True) async def on_put(self, req, resp): self.req = req self.resp = resp diff --git a/tests/test_before_hooks.py b/tests/test_before_hooks.py index bdc96ff3a..8a8aa0f6c 100644 --- a/tests/test_before_hooks.py +++ b/tests/test_before_hooks.py @@ -356,11 +356,13 @@ def test_parser_sync(body, doc): def test_parser_async(body, doc): with disable_asgi_non_coroutine_wrapping(): class WrappedRespondersBodyParserAsyncResource: - @falcon.before(validate_param_async, 'limit', 100) + @falcon.before(validate_param_async, 'limit', 100, is_async=True) @falcon.before(parse_body_async) async def on_get(self, req, resp, doc=None): - self.req = req - self.resp = resp + self.doc = doc + + @falcon.before(parse_body_async, is_async=False) + async def on_put(self, req, resp, doc=None): self.doc = doc app = create_app(asgi=True) @@ -371,6 +373,9 @@ async def on_get(self, req, resp, doc=None): testing.simulate_get(app, '/', body=body) assert resource.doc == doc + testing.simulate_put(app, '/', body=body) + assert resource.doc == doc + async def test_direct(): resource = WrappedRespondersBodyParserAsyncResource() diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 368d44165..e2a20dec7 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -1,6 +1,10 @@ from datetime import datetime import json +try: + import cython +except ImportError: + cython = None import pytest import falcon @@ -75,13 +79,13 @@ class TransactionIdMiddlewareAsync: def __init__(self): self._mw = TransactionIdMiddleware() - async def process_request_async(self, req, resp): + async def process_request(self, req, resp): self._mw.process_request(req, resp) - async def process_resource_async(self, req, resp, resource, params): + async def process_resource(self, req, resp, resource, params): self._mw.process_resource(req, resp, resource, params) - async def process_response_async(self, req, resp, resource, req_succeeded): + async def process_response(self, req, resp, resource, req_succeeded): self._mw.process_response(req, resp, resource, req_succeeded) @@ -957,6 +961,7 @@ def test_api_initialization_with_cors_enabled_and_middleware_param(self, mw, asg assert result.headers['Access-Control-Allow-Origin'] == '*' +@pytest.mark.skipif(cython, reason='Cythonized coroutine functions cannot be detected') def test_async_postfix_method_must_be_coroutine(): class FaultyComponentA: def process_request_async(self, req, resp): diff --git a/tox.ini b/tox.ini index e797748cf..20e7f5efd 100644 --- a/tox.ini +++ b/tox.ini @@ -100,29 +100,36 @@ setenv = FALCON_ASGI_WRAP_NON_COROUTINES=Y FALCON_TESTING_SESSION=Y PYTHONASYNCIODEBUG=1 +install_command = python -m pip install --no-build-isolation {opts} {packages} commands = pytest tests [] [testenv:py35_cython] basepython = python3.5 +install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py36_cython] basepython = python3.6 +install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py37_cython] basepython = python3.7 +install_command = {[with-cython]install_command} deps = {[with-cython]deps} setenv = {[with-cython]setenv} commands = {[with-cython]commands} [testenv:py38_cython] basepython = python3.8 +install_command = {[with-cython]install_command} deps = {[with-cython]deps} +setenv = {[with-cython]setenv} +commands = {[with-cython]commands} # -------------------------------------------------------------------- # Smoke testing with a sample app