Repository: falconry/falcon

Issue:
perf(Request): Short-circuit path encode/decode when possible
See if we can quickly determine if the following is required for a given path and short-circuit it when it is not (from `Request.__init__`):

```py
# PEP 3333 specifies that PATH_INFO variable are always
# "bytes tunneled as latin-1" and must be encoded back
path = path.encode('latin1').decode('utf-8', 'replace')
```

See also this section from `falcon/testing/helpers.py`:

```py
# NOTE(kgriffs): The decoded path may contain UTF-8 characters.
# But according to the WSGI spec, no strings can contain chars
# outside ISO-8859-1. Therefore, to reconcile the URI
# encoding standard that allows UTF-8 with the WSGI spec
# that does not, WSGI servers tunnel the string via
# ISO-8859-1. falcon.testing.create_environ() mimics this
# behavior, e.g.:
#
#   tunnelled_path = path.encode('utf-8').decode('iso-8859-1')
#
# falcon.Request does the following to reverse the process:
#
#   path = tunnelled_path.encode('iso-8859-1').decode('utf-8', 'replace')
#
path = path.encode('utf-8').decode('iso-8859-1')
```


Discussion:
FWIW, one way to quickly check whether a string contains only allowed chars:

```py
contains_only_allowed_chars = not some_string.rstrip(allowed_chars)
```
I was testing this briefly, and I cannot get any speedups from the `rstrip` / `lstrip` approach...

One thing that seems to work is to do EAFP this way:
```python
try:
    path.encode('ascii')
except ValueError:
    path = path.encode('latin1').decode('utf-8', 'replace')
```
However, as it is often the case with EAFP, the exception path becomes way more expensive.
But maybe it is a quite unusual case as normally URIs should be encoded in the first place?

Another approach is of course... Cython :slightly_smiling_face: 
FWIW, rewriting this in Cython to avoid memory allocation and EAFP doesn't seem to make any difference from the cythonized `path.encode('ascii')`.
However, it works much better compared to the exception path, so maybe worth implementing?
Thoughts @kgriffs ?

**Edit**: one seems to gain a lot from just binding the type to `unicode` in Cython. This allows Cython to directly call the relevant CPython C API encode/decode functions.

Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -24,6 +24,7 @@
 from falcon.media import Handlers
 from falcon.util import json
 from falcon.util import structures
+from falcon.util.misc import isascii
 from falcon.util.uri import parse_host, parse_query_string
 from falcon.vendor import mimeparse
 
@@ -464,8 +465,11 @@ def __init__(self, env, options=None):
         # ISO-8859-1, e.g.:
         #
         #   tunnelled_path = path.encode('utf-8').decode('iso-8859-1')
-        #
-        path = path.encode('iso-8859-1').decode('utf-8', 'replace')
+
+        # perf(vytas): Only decode the tunnelled path in case it is not ASCII.
+        #   For ASCII-strings, the below decoding chain is a no-op.
+        if not isascii(path):
+            path = path.encode('iso-8859-1').decode('utf-8', 'replace')
 
         if (self.options.strip_url_path_trailing_slash and
                 len(path) != 1 and path.endswith('/')):
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -36,6 +36,11 @@
 
 from falcon import status_codes
 
+try:
+    from falcon.cyutil.misc import isascii as _cy_isascii
+except ImportError:
+    _cy_isascii = None
+
 __all__ = (
     'is_python_func',
     'deprecated',
@@ -508,3 +513,31 @@ def wraps(*args, **kwargs):
         return wraps
 
     return deprecated_args
+
+
+def _isascii(string):
+    """Return ``True`` if all characters in the string are ASCII.
+
+    ASCII characters have code points in the range U+0000-U+007F.
+
+    Note:
+        On Python 3.7+, this function is just aliased to ``str.isascii``.
+
+    This is a pure-Python fallback for older CPython (where Cython is
+    unavailable) and PyPy versions.
+
+    Args:
+        string (str): A string to test.
+
+    Returns:
+        ``True`` if all characters are ASCII, ``False`` otherwise.
+    """
+
+    try:
+        string.encode('ascii')
+        return True
+    except ValueError:
+        return False
+
+
+isascii = getattr(str, 'isascii', _cy_isascii or _isascii)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -498,6 +498,21 @@ def test_secure_filename_empty_value(self):
         with pytest.raises(ValueError):
             misc.secure_filename('')
 
+    @pytest.mark.parametrize('string,expected_ascii', [
+        ('', True),
+        ('/', True),
+        ('/api', True),
+        ('/data/items/something?query=apples%20and%20oranges', True),
+        ('/food?item=ð\x9f\x8d\x94', False),
+        ('\x00\x00\x7F\x00\x00\x7F\x00', True),
+        ('\x00\x00\x7F\x00\x00\x80\x00', False),
+    ])
+    def test_misc_isascii(self, string, expected_ascii):
+        if expected_ascii:
+            assert misc.isascii(string)
+        else:
+            assert not misc.isascii(string)
+
 
 @pytest.mark.parametrize(
     'protocol,method',