Repository: pallets/werkzeug

Issue:
Sending >=500 KB file with test client causes warning
<!-- **This issue tracker is a tool to address bugs in Flask itself.
Please use the Pallets Discord or Stack Overflow for general questions
about using Flask or issues not related to Flask.** -->

<!-- If you'd like to report a bug in Flask, fill out the template below. Provide
any extra information that may be useful / related to your problem.
Ideally, create an [MCVE](https://stackoverflow.com/help/mcve), which helps us
understand the problem and helps check that it is not caused by something in
your code. -->

### Expected Behavior
An endpoint with a POST method is created, along with a unittest to test its behavior. I create two dummy files (you can include your own files) one with size 499 KB, one with size 500 KB. I pass both files to the endpoint in my unittest (by running `python3 -m unittest example.py`)

```
python3.6 -m venv venv
venv/bin/pip install flask
```

```python
import unittest
from flask import Flask

app = Flask(__name__)

@app.route("/", methods=["POST"])
def hello():
  return "hello world"

# create dummy files
with open("./small.txt", "wb") as f:
    f.write(b"0" * 1024 * 499)

with open("./big.txt", "wb") as f:
    f.write(b"0" * 1024 * 500)

# test the endpoint
class ApiTest(unittest.TestCase):
  def test_all(self):
    data = (
      ("hello world", "./big.txt"), # warning
      ("hello world", "./small.txt"), # no warning
    )

    for expected, path in data:
      with open(path, "rb") as file, app.test_client() as cl:
        response = cl.post("/", data={"file":file}).get_data().decode("utf-8")
        
      self.assertEqual(expected, response)
```

```
venv/bin/python3.6 -m unittest example.py
```

### Actual Behavior
POST-ing the bigger file (>=500 KB) produces a `ResourceWarning`
Console output:

```pytb
/home/myname/.local/lib/python3.6/site-packages/flask/testing.py:244: ResourceWarning: unclosed file <_io.BufferedRandom name=4>
  top = _request_ctx_stack.top
.
----------------------------------------------------------------------
Ran 1 test in 0.005s

OK
```
Changing from `f.write(b"0" * 1024 * 500)` to `f.write(b"0" * 1024 * 499)` the output is

```
.
----------------------------------------------------------------------
Ran 1 test in 0.004s

OK
```

### Environment

* Python version: 3.6.9
* Flask version: 1.1.1
* Werkzeug version: 1.0.0



Discussion:
From `werkzeug.test.EnvironBuilder`:

`data` can be any of these values:
-   a `str` or `bytes` object: The object is converted into an
    :attr:`input_stream`, the :attr:`content_length` is set and you have to
    provide a :attr:`content_type`.
-   a `dict` or :class:`MultiDict`: The keys have to be strings. The values
    have to be either any of the following objects, or a list of any of the
    following objects:
    -   a :class:`file`-like object:  These are converted into
        :class:`FileStorage` objects automatically.
    -   a `tuple`:  The :meth:`~FileMultiDict.add_file` method is called
        with the key and the unpacked `tuple` items as positional
        arguments.
    -   a `str`:  The string is set as form data for the associated key.
-   a file-like object: The object content is loaded in memory and then
    handled like a regular `str` or a `bytes`.

It seems the warning only shows when `data` is passed in the form
`data={'key': file}`. Passing the same file as `data=file`, `data={'file': file.read()}`
or `data=file.read()` does not trigger the warning. Perhaps this has something to do with
the conversion/handling of the `FileStorage` wrapper? On a side note, when the same test is
run with Pytest, no warning is issued. That makes me wonder if this is a Werkzeug issue at all.

_Python:  3.7.7
Flask: 1.1.2
Werkzeug: 1.0.1_
Working on this. Will keep updated.
A few findings:

1. The ResourceWarning is caused from the TemporaryFile in https://github.com/pallets/werkzeug/blob/f56d2245a7d43c293a362bb475af7b1db187d6c9/src/werkzeug/test.py#L59-L61 
There doesn't seem to be a clean way of dealing with this. The `TemporaryFile` still needs to be read from once passed into the environ dict. This means closing it along with the other files won't work.
https://github.com/pallets/werkzeug/blob/f56d2245a7d43c293a362bb475af7b1db187d6c9/src/werkzeug/test.py#L709

2. Like @northernSage said, this only happens when using `unittest`. Pytest does not show this warning. Additionally, this is *still* happening on python 3.8.

![Screen Shot 2020-07-01 at 1 42 44 PM](https://user-images.githubusercontent.com/22359229/86289571-cc00fe00-bba0-11ea-98dc-ab3747ecc1b0.png)

No issues using pytest.
![Screen Shot 2020-07-01 at 1 44 15 PM](https://user-images.githubusercontent.com/22359229/86289681-f9e64280-bba0-11ea-9ae2-ef6e9bc439ea.png)

3. The reason passing data through with `data={'file': file.read()}` (or `data=file`) doesn't cause the warning is because it is handled differently through `BytesIO` https://github.com/pallets/werkzeug/blob/f56d2245a7d43c293a362bb475af7b1db187d6c9/src/werkzeug/test.py#L380-L383


@davidism thoughts on next steps?
I can't reproduce this issue on Python 3.6+, Werkzeug 1.0.x or latest. I tried both the original example and a simplified one that only used Werkzeug.

<details><summary>Werkzeug example</summary>

```python
import unittest

from werkzeug import Request
from werkzeug import Response
from werkzeug import Client


@Request.application
def app(request):
    return Response("Hello, World!")


with open("zero.txt", "wb") as f:
    f.write(b"0" * 1_000_000)


class APITest(unittest.TestCase):
    def test_file(self):
        c = Client(app)

        with open("zero.txt", "rb") as f:
            r = c.post(data={"file": f})

        assert r.get_data(as_text=True) == "Hello, World!"


if __name__ == "__main__":
    unittest.main()
```

```
python -Walways -m unittest example
```

</details>

As far as I can tell, the open files passed to `data={}` are closed when `EnvironBuilder.close()` is called, so that's not the issue. The file that's not being closed is `BufferedRandom`, which means it's the temp file created by `stream_encode_multipart` to be passed as `wsgi.input_stream`. `Client.open` *could* close the input stream after getting the response object, but the problem is that the response might be streaming, so closing the input stream might cause the view that's streaming output to fail, depending on what it's doing.

Now that the test client always returns `TestResponse` objects, there's a few options, but they still require user intervention. You can call `response.request.input_stream.close()` to close the input stream if you're getting this error. We could also make this easier by registering it with `Response.call_on_close()`, so you could call `response.close()` (which you sometimes have to do for other reasons anyway, such as when serving a file).

You can close the input stream in the current 1.0.x release, it's just a bit more complicated. You need to pass `as_tuple=True` when making requests, which will return `environ, result`. Or use `warnings.filterwarnings` to ignore the warning.

```python
environ, result = client.post("/", data={"file": f})
assert result.get_data() == expect
environ["wsgi.input"].close()
```
I'm not sure if I should make my proposed fix right now, since I can't reproduce the issue. I'll see if I can have someone else test it.
Never mind, I just tried it again and now I can reproduce it consistently. Weird. But if I install the latest development Werkzeug instead of 1.0.1, the issue goes away. But if I run with `python -Walways -m unittest example.py`, it shows up again. Not sure what caused it to start being ignored.

Resolving patch:
diff --git a/src/werkzeug/wrappers/request.py b/src/werkzeug/wrappers/request.py
--- a/src/werkzeug/wrappers/request.py
+++ b/src/werkzeug/wrappers/request.py
@@ -361,9 +361,9 @@ def stream(self) -> t.BinaryIO:
         _assert_not_shallow(self)
         return get_input_stream(self.environ)
 
-    input_stream = environ_property(
+    input_stream = environ_property[t.BinaryIO](
         "wsgi.input",
-        """The WSGI input stream.
+        doc="""The WSGI input stream.
 
         In general it's a bad idea to use this one because you can
         easily read past the boundary.  Use the :attr:`stream`


Test patch:
diff --git a/src/werkzeug/test.py b/src/werkzeug/test.py
--- a/src/werkzeug/test.py
+++ b/src/werkzeug/test.py
@@ -994,7 +994,11 @@ def resolve_redirect(
                 builder.method = "GET"
 
             # Clear the body and the headers that describe it.
-            builder.input_stream = None
+
+            if builder.input_stream is not None:
+                builder.input_stream.close()
+                builder.input_stream = None
+
             builder.content_type = None
             builder.content_length = None
             builder.headers.pop("Transfer-Encoding", None)
@@ -1028,6 +1032,11 @@ def open(
             2.1. Use :attr:`TestResponse.request` and
             ``request.environ`` instead.
 
+        .. versionchanged:: 2.0.0
+            The request input stream is closed when calling
+            ``response.close()``. Input streams for redirects are
+            automatically closed.
+
         .. versionchanged:: 0.5
             If a dict is provided as file in the dict for the ``data``
             parameter the content type has to be called ``content_type``
@@ -1089,14 +1098,21 @@ def open(
             response.history = tuple(history)
             history.append(response)
             response = self.resolve_redirect(response, buffered=buffered)
-
-        response.history = tuple(history)
+        else:
+            # This is the final request after redirects, or not
+            # following redirects.
+            response.history = tuple(history)
+            # Close the input stream when closing the response, in case
+            # the input is an open temporary file.
+            response.call_on_close(request.input_stream.close)
 
         if as_tuple:
             warnings.warn(
                 "'as_tuple' is deprecated and will be removed in"
                 " version 2.1. Access 'response.request.environ'"
-                " instead."
+                " instead.",
+                DeprecationWarning,
+                stacklevel=2,
             )
             return request.environ, response  # type: ignore
 
@@ -1248,6 +1264,10 @@ class TestResponse(Response):
     Test client requests will always return an instance of this class.
     If a custom response class is passed to the client, it is
     subclassed along with this to support test information.
+
+    If the test request included large files, or if the application is
+    serving a file, call :meth:`close` to close any open files and
+    prevent Python showing a ``ResourceWarning``.
     """
 
     request: Request