Repository: falconry/falcon
Pull request #1657 (2020-01-28 03:37:53)

fix(asgi): Errors caused by not being able to detect cythonized coroutine functions
# Summary of Changes

This patch provides workarounds for the fact that cythonized coroutine functions
can not be detected.

In several places in the framework, we rely on being able to introspect a passed
callable in order to determine how to wrap it or call it later. As a workaround,
this patch gives the caller a way to explicitly indicate that the framework may
assume a coroutine function.

Also, we relax our type checking so that we don't raise an error on Cython
functions; the app developer will simply have to be trusted to pass the correct
type of function if they are using Cython.

# Pull Request Checklist

This is just a reminder about the most common mistakes.  Please make sure that you tick all *appropriate* boxes.  But please read our [contribution guide](https://github.com/falconry/falcon/blob/master/CONTRIBUTING.md) at least once; it will save you a few review cycles!

If an item doesn't apply to your pull request, **check it anyway** to make it apparent that there's nothing to do.

- [x] Added **tests** for changed code.
- [x] Prefixed code comments with GitHub nick and an appropriate prefix.
- [x] Coding style is consistent with the rest of the framework.
- [x] Updated **documentation** for changed code.
    - [x] Added docstrings for any new classes, functions, or modules.
    - [x] Updated docstrings for any modifications to existing code.
    - [x] Added references to new classes, functions, or modules to the relevant RST file under `docs/`.
    - [x] Updated all relevant supporting documentation files under `docs/`.
    - [x] A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    - [x] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/usage/restructuredtext/directives.html?highlight=versionadded#directive-versionadded).
- [x] Changes (and possible deprecations) have [towncrier](https://pypi.org/project/towncrier/) news fragments under `docs/_newsfragments/`. (Run `towncrier --draft` to ensure it renders correctly.)

If you have *any* questions to *any* of the points above, just **submit and ask**! This checklist is here to *help* you, not to deter you from contributing!

*PR template inspired by the attrs project.*


codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=h1) Report
> Merging [#1657](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/00e5c9c2499c3afb5f8d48fca80591531563d461?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1657/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1657   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          45      45           
  Lines        3123    3134   +11     
  Branches      482     482           
======================================
+ Hits         3123    3134   +11
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/asgi/response.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVzcG9uc2UucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [falcon/asgi/app.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvYXBwLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [falcon/hooks.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL2hvb2tzLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [falcon/util/misc.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvbWlzYy5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [falcon/media/validators/jsonschema.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL3ZhbGlkYXRvcnMvanNvbnNjaGVtYS5weQ==) | `100% <100%> (ø)` | :arrow_up: |
| [falcon/routing/compiled.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL3JvdXRpbmcvY29tcGlsZWQucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [falcon/app\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1657/diff?src=pr&el=tree#diff-ZmFsY29uL2FwcF9oZWxwZXJzLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=footer). Last update [00e5c9c...8b121b6](https://codecov.io/gh/falconry/falcon/pull/1657?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kgriffs: @vytas7 I attempted to address all of your feedback with this update.

Patch:
diff --git a/docs/api/util.rst b/docs/api/util.rst
index fa8437d..088f19a 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 f6b4ce2..02015f2 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 5d28621..fb11f09 100644
--- a/falcon/asgi/app.py
+++ b/falcon/asgi/app.py
@@ -23,7 +23,7 @@ from falcon.errors import CompatibilityError, UnsupportedError, UnsupportedScope
 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 @@ class App(falcon.app.App):
 
         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 @@ class App(falcon.app.App):
 
         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/response.py b/falcon/asgi/response.py
index 65fa96c..253facc 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 @@ class Response(falcon.response.Response):
         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 @@ class Response(falcon.response.Response):
             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 @@ class Response(falcon.response.Response):
                 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 82c0502..79f1b12 100644
--- a/falcon/hooks.py
+++ b/falcon/hooks.py
@@ -27,7 +27,7 @@ _DECORABLE_METHOD_NAME = re.compile(r'^on_({})(_\w+)?$'.format(
     '|'.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 before(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*.
     """
@@ -72,7 +88,13 @@ def before(action, *args, **kwargs):
                     # 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 before(action, *args, **kwargs):
 
         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(action, *args, **kwargs):
             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 after(action, *args, **kwargs):
 
         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 after(action, *args, **kwargs):
 # -----------------------------------------------------------------------------
 
 
-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 _wrap_with_after(responder, action, action_args, action_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 9597ee4..1a3863e 100644
--- a/falcon/media/validators/jsonschema.py
+++ b/falcon/media/validators/jsonschema.py
@@ -9,7 +9,7 @@ except ImportError:  # pragma: nocover
     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 <http://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 validate(req_schema=None, resp_schema=None):
     """
 
     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 14497d6..96ebbfa 100644
--- a/falcon/routing/compiled.py
+++ b/falcon/routing/compiled.py
@@ -22,6 +22,7 @@ import textwrap
 
 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 @@ class CompiledRouter:
             #   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/util/misc.py b/falcon/util/misc.py
index 4cd3b28..d9962a8 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -35,6 +35,7 @@ import warnings
 from falcon import status_codes
 
 __all__ = (
+    'is_python_func',
     'deprecated',
     'http_now',
     'dt_to_http',
@@ -77,6 +78,30 @@ else:
     _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 6aef39f..9a88ccb 100644
--- a/setup.py
+++ b/setup.py
@@ -28,14 +28,6 @@ else:
         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 @@ if CYTHON:
 
     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/tox.ini b/tox.ini
index e797748..20e7f5e 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


Test patch:
diff --git a/falcon/testing/client.py b/falcon/testing/client.py
index 2722a36..1475ff6 100644
--- a/falcon/testing/client.py
+++ b/falcon/testing/client.py
@@ -211,7 +211,7 @@ class Result:
         #   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/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py
index cf8a480..7a76a6b 100644
--- a/tests/asgi/_asgi_test_app.py
+++ b/tests/asgi/_asgi_test_app.py
@@ -28,9 +28,9 @@ class Things:
             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 0000000..5624ba6
--- /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 0000000..d1758d5
--- /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 19c94f6..c9a5d63 100644
--- a/tests/asgi/test_scheduled_callbacks.py
+++ b/tests/asgi/test_scheduled_callbacks.py
@@ -19,10 +19,13 @@ def test_multiple():
             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 test_multiple():
 
             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 bef116e..f332898 100644
--- a/tests/test_after_hooks.py
+++ b/tests/test_after_hooks.py
@@ -132,12 +132,12 @@ class WrappedRespondersResource:
 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 02a7c94..bbe2d0a 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 @@ def test_parser_async(body, doc):
     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 1ca6ad8..174df18 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 @@ class TestCORSMiddlewareWithAnotherMiddleware(TestMiddleware):
         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):