Repository: falconry/falcon

Issue:
Move falcon.request_helpers.BoundedStream to its own module
The ASGI version is in it's own module, so let's do the same thing on the WSGI side. It should make it a bit more on-topic to have it in a separate module, and make it easier for contributors to track it down. This will also let us "privatize" request_helpers in the future without affecting BoundedStream (which is obviously part of the public interface).

This is a breaking change, but shouldn't cause much trouble because apps generally just refer to this class via the req.bounded_stream instance.

Be sure to also update any relevant docs and include a news fragment noting the breaking change.

`falcon.request_helpers.BoundedStream ==> falcon.stream.BoundedStream`


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1781?src=pr&el=h1) Report
> Merging [#1781](https://codecov.io/gh/falconry/falcon/pull/1781?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/820c51b5f0af8d1c6e9fb8cccb560b6366f97332?el=desc) will **decrease** coverage by `32.28%`.
> The diff coverage is `100.00%`.

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

```diff
@@             Coverage Diff              @@
##            master    #1781       +/-   ##
============================================
- Coverage   100.00%   67.71%   -32.29%     
============================================
  Files           53       53               
  Lines         4445     4408       -37     
  Branches       701      699        -2     
============================================
- Hits          4445     2985     -1460     
- Misses           0     1413     +1413     
- Partials         0       10       +10     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1781?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/media/multipart.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL21lZGlhL211bHRpcGFydC5weQ==) | `97.64% <100.00%> (-2.36%)` | :arrow_down: |
| [falcon/request\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL3JlcXVlc3RfaGVscGVycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [falcon/asgi/app.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvYXBwLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/reader.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVhZGVyLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/stream.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RyZWFtLnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/request.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVxdWVzdC5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/response.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvcmVzcG9uc2UucHk=) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/multipart.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvbXVsdGlwYXJ0LnB5) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/structures.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvc3RydWN0dXJlcy5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| [falcon/asgi/\_request\_helpers.py](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree#diff-ZmFsY29uL2FzZ2kvX3JlcXVlc3RfaGVscGVycy5weQ==) | `0.00% <0.00%> (-100.00%)` | :arrow_down: |
| ... and [24 more](https://codecov.io/gh/falconry/falcon/pull/1781/diff?src=pr&el=tree-more) | |

------

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

CaselIT: Updated. I've added a test that actually checks the import.

Patch:
diff --git a/docs/_newsfragments/1583.breakingchange.rst b/docs/_newsfragments/1583.breakingchange.rst
new file mode 100644
index 0000000..fb1b07c
--- /dev/null
+++ b/docs/_newsfragments/1583.breakingchange.rst
@@ -0,0 +1,3 @@
+Referencing the class :class:`falcon.stream.BoundedStream` through the
+``falcon.request_helpers`` module is deprecated. It is now accessible from
+the module ``falcon.stream``.
diff --git a/docs/api/request_and_response_wsgi.rst b/docs/api/request_and_response_wsgi.rst
index ccca0d0..540f26f 100644
--- a/docs/api/request_and_response_wsgi.rst
+++ b/docs/api/request_and_response_wsgi.rst
@@ -40,6 +40,9 @@ Request
 .. autoclass:: falcon.Forwarded
     :members:
 
+.. autoclass:: falcon.stream.BoundedStream
+    :members:
+
 Response
 --------
 
diff --git a/falcon/__init__.py b/falcon/__init__.py
index 05c559c..c90d708 100644
--- a/falcon/__init__.py
+++ b/falcon/__init__.py
@@ -36,6 +36,7 @@ from falcon.errors import *  # NOQA
 from falcon.redirects import *  # NOQA
 from falcon.http_error import HTTPError  # NOQA
 from falcon.http_status import HTTPStatus  # NOQA
+from falcon.stream import BoundedStream # NOQA
 from falcon.middleware import CORSMiddleware  # NOQA
 
 # NOTE(kgriffs): Ensure that "from falcon import uri" will import
diff --git a/falcon/media/multipart.py b/falcon/media/multipart.py
index 10b24db..8097d50 100644
--- a/falcon/media/multipart.py
+++ b/falcon/media/multipart.py
@@ -19,12 +19,13 @@ import re
 from urllib.parse import unquote_to_bytes
 
 from falcon import errors
-from falcon import request_helpers
 from falcon.media.base import BaseHandler
+from falcon.stream import BoundedStream
 from falcon.util import BufferedReader
 from falcon.util import misc
 from falcon.util.deprecation import deprecated_args
 
+
 # TODO(vytas):
 #   * Better support for form-wide charset setting
 #   * Clean up, simplify, and optimize BufferedReader
@@ -381,7 +382,7 @@ class MultipartForm:
         # This approach makes testing both the Cythonized and pure-Python
         #   streams easier within the same test/benchmark suite.
         if not hasattr(stream, 'read_until'):
-            if isinstance(stream, request_helpers.BoundedStream):
+            if isinstance(stream, BoundedStream):
                 stream = BufferedReader(stream.stream.read, content_length)
             else:
                 stream = BufferedReader(stream.read, content_length)
diff --git a/falcon/request.py b/falcon/request.py
index a9f3387..2355763 100644
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -25,6 +25,7 @@ from falcon.forwarded import _parse_forwarded_header
 from falcon.forwarded import Forwarded  # NOQA
 from falcon.media import Handlers
 from falcon.media.json import _DEFAULT_JSON_HANDLER
+from falcon.stream import BoundedStream
 from falcon.util import structures
 from falcon.util.misc import isascii
 from falcon.util.uri import parse_host, parse_query_string
@@ -1817,7 +1818,7 @@ class Request:
             # but it had an invalid value. Assume no content.
             content_length = 0
 
-        return helpers.BoundedStream(self.env['wsgi.input'], content_length)
+        return BoundedStream(self.env['wsgi.input'], content_length)
 
     def _parse_form_urlencoded(self):
         content_length = self.content_length
diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py
index f0e3eee..b273feb 100644
--- a/falcon/request_helpers.py
+++ b/falcon/request_helpers.py
@@ -15,9 +15,11 @@
 """Utilities for the Request class."""
 
 from http import cookies as http_cookies
-import io
 import re
 
+# TODO: BoundedStream  import here is for backwards-compatibility
+# and it should be removed in Falcon 4.0
+from falcon.stream import BoundedStream, Body  # NOQA
 from falcon.util import ETag
 
 # https://tools.ietf.org/html/rfc6265#section-4.1.1
@@ -169,153 +171,3 @@ def _parse_etags(etag_str):
     #   are all set to nothing, and so therefore basically should be
     #   treated as not having been set in the first place.
     return etags or None
-
-
-class BoundedStream(io.IOBase):
-    """Wrap *wsgi.input* streams to make them more robust.
-
-    ``socket._fileobject`` and ``io.BufferedReader`` are sometimes used
-    to implement *wsgi.input*. However, app developers are often burned
-    by the fact that the `read()` method for these objects block
-    indefinitely if either no size is passed, or a size greater than
-    the request's content length is passed to the method.
-
-    This class normalizes *wsgi.input* behavior between WSGI servers
-    by implementing non-blocking behavior for the cases mentioned
-    above. The caller is not allowed to read more than the number of
-    bytes specified by the Content-Length header in the request.
-
-    Args:
-        stream: Instance of ``socket._fileobject`` from
-            ``environ['wsgi.input']``
-        stream_len: Expected content length of the stream.
-
-    Attributes:
-        eof (bool): ``True`` if there is no more data to read from
-            the stream, otherwise ``False``.
-        is_exhausted (bool): Deprecated alias for `eof`.
-
-    """
-
-    def __init__(self, stream, stream_len):
-        self.stream = stream
-        self.stream_len = stream_len
-
-        self._bytes_remaining = self.stream_len
-
-    def __iter__(self):
-        return self
-
-    def __next__(self):
-        return next(self.stream)
-
-    next = __next__
-
-    def _read(self, size, target):
-        """Proxy reads to the underlying stream.
-
-        Args:
-            size (int): Maximum number of bytes to read. Will be
-                coerced, if None or -1, to the number of remaining bytes
-                in the stream. Will likewise be coerced if greater than
-                the number of remaining bytes, to avoid making a
-                blocking call to the wrapped stream.
-            target (callable): Once `size` has been fixed up, this function
-                will be called to actually do the work.
-
-        Returns:
-            bytes: Data read from the stream, as returned by `target`.
-
-        """
-
-        # NOTE(kgriffs): Default to reading all remaining bytes if the
-        # size is not specified or is out of bounds. This behaves
-        # similarly to the IO streams passed in by non-wsgiref servers.
-        if (size is None or size == -1 or size > self._bytes_remaining):
-            size = self._bytes_remaining
-
-        self._bytes_remaining -= size
-        return target(size)
-
-    def readable(self):
-        """Return ``True`` always."""
-        return True
-
-    def seekable(self):
-        """Return ``False`` always."""
-        return False
-
-    def writable(self):
-        """Return ``False`` always."""
-        return False
-
-    def read(self, size=None):
-        """Read from the stream.
-
-        Args:
-            size (int): Maximum number of bytes/characters to read.
-                Defaults to reading until EOF.
-
-        Returns:
-            bytes: Data read from the stream.
-
-        """
-
-        return self._read(size, self.stream.read)
-
-    def readline(self, limit=None):
-        """Read a line from the stream.
-
-        Args:
-            limit (int): Maximum number of bytes/characters to read.
-                Defaults to reading until EOF.
-
-        Returns:
-            bytes: Data read from the stream.
-
-        """
-
-        return self._read(limit, self.stream.readline)
-
-    def readlines(self, hint=None):
-        """Read lines from the stream.
-
-        Args:
-            hint (int): Maximum number of bytes/characters to read.
-                Defaults to reading until EOF.
-
-        Returns:
-            bytes: Data read from the stream.
-
-        """
-
-        return self._read(hint, self.stream.readlines)
-
-    def write(self, data):
-        """Raise IOError always; writing is not supported."""
-
-        raise IOError('Stream is not writeable')
-
-    def exhaust(self, chunk_size=64 * 1024):
-        """Exhaust the stream.
-
-        This consumes all the data left until the limit is reached.
-
-        Args:
-            chunk_size (int): The size for a chunk (default: 64 KB).
-                It will read the chunk until the stream is exhausted.
-        """
-        while True:
-            chunk = self.read(chunk_size)
-            if not chunk:
-                break
-
-    @property
-    def eof(self):
-        return self._bytes_remaining <= 0
-
-    is_exhausted = eof
-
-
-# NOTE(kgriffs): Alias for backwards-compat
-Body = BoundedStream
diff --git a/falcon/stream.py b/falcon/stream.py
new file mode 100644
index 0000000..97b2c89
--- /dev/null
+++ b/falcon/stream.py
@@ -0,0 +1,169 @@
+# Copyright 2013 by Rackspace Hosting, Inc.
+#
+# 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.
+
+"""WSGI BoundedStream class."""
+
+import io
+
+__all__ = ['BoundedStream']
+
+
+class BoundedStream(io.IOBase):
+    """Wrap *wsgi.input* streams to make them more robust.
+
+    ``socket._fileobject`` and ``io.BufferedReader`` are sometimes used
+    to implement *wsgi.input*. However, app developers are often burned
+    by the fact that the `read()` method for these objects block
+    indefinitely if either no size is passed, or a size greater than
+    the request's content length is passed to the method.
+
+    This class normalizes *wsgi.input* behavior between WSGI servers
+    by implementing non-blocking behavior for the cases mentioned
+    above. The caller is not allowed to read more than the number of
+    bytes specified by the Content-Length header in the request.
+
+    Args:
+        stream: Instance of ``socket._fileobject`` from
+            ``environ['wsgi.input']``
+        stream_len: Expected content length of the stream.
+
+    Attributes:
+        eof (bool): ``True`` if there is no more data to read from
+            the stream, otherwise ``False``.
+        is_exhausted (bool): Deprecated alias for `eof`.
+
+    """
+
+    def __init__(self, stream, stream_len):
+        self.stream = stream
+        self.stream_len = stream_len
+
+        self._bytes_remaining = self.stream_len
+
+    def __iter__(self):
+        return self
+
+    def __next__(self):
+        return next(self.stream)
+
+    next = __next__
+
+    def _read(self, size, target):
+        """Proxy reads to the underlying stream.
+
+        Args:
+            size (int): Maximum number of bytes to read. Will be
+                coerced, if None or -1, to the number of remaining bytes
+                in the stream. Will likewise be coerced if greater than
+                the number of remaining bytes, to avoid making a
+                blocking call to the wrapped stream.
+            target (callable): Once `size` has been fixed up, this function
+                will be called to actually do the work.
+
+        Returns:
+            bytes: Data read from the stream, as returned by `target`.
+
+        """
+
+        # NOTE(kgriffs): Default to reading all remaining bytes if the
+        # size is not specified or is out of bounds. This behaves
+        # similarly to the IO streams passed in by non-wsgiref servers.
+        if (size is None or size == -1 or size > self._bytes_remaining):
+            size = self._bytes_remaining
+
+        self._bytes_remaining -= size
+        return target(size)
+
+    def readable(self):
+        """Return ``True`` always."""
+        return True
+
+    def seekable(self):
+        """Return ``False`` always."""
+        return False
+
+    def writable(self):
+        """Return ``False`` always."""
+        return False
+
+    def read(self, size=None):
+        """Read from the stream.
+
+        Args:
+            size (int): Maximum number of bytes/characters to read.
+                Defaults to reading until EOF.
+
+        Returns:
+            bytes: Data read from the stream.
+
+        """
+
+        return self._read(size, self.stream.read)
+
+    def readline(self, limit=None):
+        """Read a line from the stream.
+
+        Args:
+            limit (int): Maximum number of bytes/characters to read.
+                Defaults to reading until EOF.
+
+        Returns:
+            bytes: Data read from the stream.
+
+        """
+
+        return self._read(limit, self.stream.readline)
+
+    def readlines(self, hint=None):
+        """Read lines from the stream.
+
+        Args:
+            hint (int): Maximum number of bytes/characters to read.
+                Defaults to reading until EOF.
+
+        Returns:
+            bytes: Data read from the stream.
+
+        """
+
+        return self._read(hint, self.stream.readlines)
+
+    def write(self, data):
+        """Raise IOError always; writing is not supported."""
+
+        raise IOError('Stream is not writeable')
+
+    def exhaust(self, chunk_size=64 * 1024):
+        """Exhaust the stream.
+
+        This consumes all the data left until the limit is reached.
+
+        Args:
+            chunk_size (int): The size for a chunk (default: 64 KB).
+                It will read the chunk until the stream is exhausted.
+        """
+        while True:
+            chunk = self.read(chunk_size)
+            if not chunk:
+                break
+
+    @property
+    def eof(self):
+        return self._bytes_remaining <= 0
+
+    is_exhausted = eof
+
+
+# NOTE(kgriffs): Alias for backwards-compat
+Body = BoundedStream


Test patch:
diff --git a/tests/test_boundedstream.py b/tests/test_boundedstream.py
index f2a8cb7..3c9ec20 100644
--- a/tests/test_boundedstream.py
+++ b/tests/test_boundedstream.py
@@ -2,7 +2,7 @@ import io
 
 import pytest
 
-from falcon.request_helpers import BoundedStream
+from falcon.stream import BoundedStream
 
 
 @pytest.fixture
diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py
new file mode 100644
index 0000000..09570a3
--- /dev/null
+++ b/tests/test_deprecations.py
@@ -0,0 +1,6 @@
+from falcon import request_helpers, stream
+
+
+def test_bounded_stream():
+    assert request_helpers.Body is stream.Body
+    assert request_helpers.BoundedStream is stream.BoundedStream
diff --git a/tests/test_request_body.py b/tests/test_request_body.py
index eb33f1b..43ecab1 100644
--- a/tests/test_request_body.py
+++ b/tests/test_request_body.py
@@ -6,6 +6,7 @@ import pytest
 import falcon
 from falcon import request_helpers
 import falcon.request
+from falcon.stream import BoundedStream
 import falcon.testing as testing
 
 SIZE_1_KB = 1024
@@ -26,7 +27,7 @@ class TestRequestBody:
     def _get_wrapped_stream(self, req):
         # Getting wrapped wsgi.input:
         stream = req.stream
-        if isinstance(stream, request_helpers.BoundedStream):
+        if isinstance(stream, BoundedStream):
             stream = stream.stream
         if isinstance(stream, InputWrapper):
             stream = stream.input