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:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1682?src=pr&el=h1) Report
> Merging [#1682](https://codecov.io/gh/falconry/falcon/pull/1682?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/c3e91875f1030c68d17efa27cf8df3d18f337761?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

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

```diff
@@          Coverage Diff           @@
##           master   #1682   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          47      47           
  Lines        3568    3576    +8     
  Branches      545     545           
======================================
+ Hits         3568    3576    +8
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1682?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/util/uri.py](https://codecov.io/gh/falconry/falcon/pull/1682/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvdXJpLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1682?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/1682?src=pr&el=footer). Last update [c3e9187...c6026f5](https://codecov.io/gh/falconry/falcon/pull/1682?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

kgriffs: For noteworthy performance improvements, would it make sense to include a news fragment?
vytas7: @kgriffs It does make sense, I was just unsure what we settled on wrt the naming schema & extension categories.
But I'll add a tentative one, so we can rename later.
kgriffs: What is the impact of this change on performance without Cython? I would check 3.6 as well as 3.8. 
vytas7: I was actually rewriting this from the isascii perspective. The built-in one has better perf than Cython allows me to achieve (it allows semi-efficient unicode char iteration, but I cannot access the string memory in a compatible, non deprecated way. So I cannot process 64-bit or more per cycle). 
vytas7: @kgriffs Ready for-review now, I hope!

I'm still testing that builtin `str.isascii`. It looked like it was outperforming the cythonized version slightly, but 3.5-cythonized actually outdid 3.8 in the results presented above.
I'll add a test comparing those for 3.8.

Either way, the difference should be very insignificant, and IMHO it makes more sense to just use the builtin function. Thoughts?

Patch:
diff --git a/docs/_newsfragments/1492.newandimproved.rst b/docs/_newsfragments/1492.newandimproved.rst
new file mode 100644
index 0000000..86860b5
--- /dev/null
+++ b/docs/_newsfragments/1492.newandimproved.rst
@@ -0,0 +1,2 @@
+WSGI path decoding in :class:`falcon.Request` was optimized, and is now
+significantly faster than in Falcon 2.0.
diff --git a/falcon/cyutil/misc.pyx b/falcon/cyutil/misc.pyx
new file mode 100644
index 0000000..095dddd
--- /dev/null
+++ b/falcon/cyutil/misc.pyx
@@ -0,0 +1,40 @@
+# Copyright 2020 by Vytautas Liuolia.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+def isascii(unicode string not None):
+    """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 Cython fallback for older CPython versions. For longer strings,
+    it is slightly less performant than the built-in ``str.isascii``.
+
+    Args:
+        string (str): A string to test.
+
+    Returns:
+        ``True`` if all characters are ASCII, ``False`` otherwise.
+    """
+
+    cdef Py_UCS4 ch
+
+    for ch in string:
+        if ch > 0x007F:
+            return False
+
+    return True
diff --git a/falcon/request.py b/falcon/request.py
index 827aaaa..76c5da3 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -24,6 +24,7 @@ from falcon.forwarded import Forwarded  # NOQA
 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 @@ class Request:
         # 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
index 4eea6d6..2287e93 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -36,6 +36,11 @@ import warnings
 
 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 deprecated_args(*, allowed_positional, is_method=True):
         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
index d393a09..c611c2c 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -498,6 +498,21 @@ class TestFalconUtils:
         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',