Repository: getsentry/responses

Issue:
Cannot match request with `data=filehandle` 
### Describe the bug

When mocking responses to a request where an open filehandle is passed as the `data` argument to the request function/method, the prepared request passed to the matcher has its `body` attribute set to the filehandle rather than to the filehandle's contents (as would be sent in an actual request).

### Additional context

_No response_

### Version of `responses`

0.25.0

### Steps to Reproduce

Run `pytest` on the following file:

```python
from __future__ import annotations
from pathlib import Path
import requests
import responses


@responses.activate
def test_post_file(tmp_path: Path) -> None:
    CONTENT = b"This is test text.\n"

    def match_body(req: requests.PreparedRequest) -> tuple[bool, str]:
        if req.body == CONTENT:
            return (True, "")
        else:
            return (False, f"Request body is not the expected content: {req.body!r}")

    responses.post(
        "http://example.nil/endpoint",
        status=200,
        json={"success": True},
        match=[match_body],
    )
    p = tmp_path / "foo.txt"
    p.write_bytes(CONTENT)
    with requests.Session() as s:
        with p.open("rb") as fp:
            assert s.post("http://example.nil/endpoint", data=fp) == {"success": True}
```

### Expected Result

Matcher should successfully match the request body against the expected bytes, resulting in the test passing without error.

### Actual Result

```
============================= test session starts ==============================
platform darwin -- Python 3.12.3, pytest-8.2.1, pluggy-1.5.0
rootdir: /Users/jwodder/work/dev/tmp/responses-bug
collected 1 item

test.py F                                                                [100%]

=================================== FAILURES ===================================
________________________________ test_post_file ________________________________

tmp_path = PosixPath('/private/var/folders/l7/wrkq93d133d8zpn36fmqrq0r0000gn/T/pytest-of-jwodder/pytest-91/test_post_file0')

    @responses.activate
    def test_post_file(tmp_path: Path) -> None:
        CONTENT = b"This is test text.\n"
    
        def match_body(req: requests.PreparedRequest) -> tuple[bool, str]:
            if req.body == CONTENT:
                return (True, "")
            else:
                return (False, f"Request body is not the expected content: {req.body!r}")
    
        responses.post(
            "http://example.nil/endpoint",
            status=200,
            json={"success": True},
            match=[match_body],
        )
        p = tmp_path / "foo.txt"
        p.write_bytes(CONTENT)
        with requests.Session() as s:
            with p.open("rb") as fp:
>               assert s.post("http://example.nil/endpoint", data=fp) == {"success": True}

test.py:27: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../../../.local/virtualenvwrapper/venvs/tmp-ca431dc82c2b8f1/lib/python3.12/site-packages/requests/sessions.py:637: in post
    return self.request("POST", url, data=data, json=json, **kwargs)
../../../../.local/virtualenvwrapper/venvs/tmp-ca431dc82c2b8f1/lib/python3.12/site-packages/requests/sessions.py:589: in request
    resp = self.send(prep, **send_kwargs)
../../../../.local/virtualenvwrapper/venvs/tmp-ca431dc82c2b8f1/lib/python3.12/site-packages/requests/sessions.py:703: in send
    r = adapter.send(request, **kwargs)
../../../../.local/virtualenvwrapper/venvs/tmp-ca431dc82c2b8f1/lib/python3.12/site-packages/responses/__init__.py:1173: in send
    return self._on_request(adapter, request, **kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <responses.RequestsMock object at 0x10b0ffad0>
adapter = <requests.adapters.HTTPAdapter object at 0x10b0dbef0>
request = <PreparedRequest [POST]>, retries = None
kwargs = {'cert': None, 'proxies': OrderedDict(), 'stream': False, 'timeout': None, ...}
request_url = 'http://example.nil/endpoint', match = None
match_failed_reasons = ["Request body is not the expected content: <_io.BufferedReader name='/private/var/folders/l7/wrkq93d133d8zpn36fmqrq0r0000gn/T/pytest-of-jwodder/pytest-91/test_post_file0/foo.txt'>"]
resp_callback = None
error_msg = "Connection refused by Responses - the call doesn't match any registered mock.\n\nRequest: \n- POST http://example.nil...name='/private/var/folders/l7/wrkq93d133d8zpn36fmqrq0r0000gn/T/pytest-of-jwodder/pytest-91/test_post_file0/foo.txt'>\n"

    def _on_request(
        self,
        adapter: "HTTPAdapter",
        request: "PreparedRequest",
        *,
        retries: Optional["_Retry"] = None,
        **kwargs: Any,
    ) -> "models.Response":
        # add attributes params and req_kwargs to 'request' object for further match comparison
        # original request object does not have these attributes
        request.params = self._parse_request_params(request.path_url)  # type: ignore[attr-defined]
        request.req_kwargs = kwargs  # type: ignore[attr-defined]
        request_url = str(request.url)
    
        match, match_failed_reasons = self._find_match(request)
        resp_callback = self.response_callback
    
        if match is None:
            if any(
                [
                    p.match(request_url)
                    if isinstance(p, Pattern)
                    else request_url.startswith(p)
                    for p in self.passthru_prefixes
                ]
            ):
                logger.info("request.allowed-passthru", extra={"url": request_url})
                return self._real_send(adapter, request, **kwargs)  # type: ignore
    
            error_msg = (
                "Connection refused by Responses - the call doesn't "
                "match any registered mock.\n\n"
                "Request: \n"
                f"- {request.method} {request_url}\n\n"
                "Available matches:\n"
            )
            for i, m in enumerate(self.registered()):
                error_msg += "- {} {} {}\n".format(
                    m.method, m.url, match_failed_reasons[i]
                )
    
            if self.passthru_prefixes:
                error_msg += "Passthru prefixes:\n"
                for p in self.passthru_prefixes:
                    error_msg += f"- {p}\n"
    
            response = ConnectionError(error_msg)
            response.request = request
    
            self._calls.add(request, response)
>           raise response
E           requests.exceptions.ConnectionError: Connection refused by Responses - the call doesn't match any registered mock.
E           
E           Request: 
E           - POST http://example.nil/endpoint
E           
E           Available matches:
E           - POST http://example.nil/endpoint Request body is not the expected content: <_io.BufferedReader name='/private/var/folders/l7/wrkq93d133d8zpn36fmqrq0r0000gn/T/pytest-of-jwodder/pytest-91/test_post_file0/foo.txt'>

../../../../.local/virtualenvwrapper/venvs/tmp-ca431dc82c2b8f1/lib/python3.12/site-packages/responses/__init__.py:1100: ConnectionError
=========================== short test summary info ============================
FAILED test.py::test_post_file - requests.exceptions.ConnectionError: Connect...
============================== 1 failed in 0.18s ===============================
```

Discussion:
markstory: Looking good, but there are a few CI failures due to missing imports.
mrob95: > Looking good, but there are a few CI failures due to missing imports.

Ah whoops, I think I have fixed the missing import and the line length lint failure now.

Patch:
diff --git a/responses/__init__.py b/responses/__init__.py
index 097e61e..fb37516 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -1054,6 +1054,22 @@ class RequestsMock:
             params[key] = values
         return params
 
+    def _read_filelike_body(
+        self, body: Union[str, bytes, BufferedReader, None]
+    ) -> Union[str, bytes, None]:
+        # Requests/urllib support multiple types of body, including file-like objects.
+        # Read from the file if it's a file-like object to avoid storing a closed file
+        # in the call list and allow the user to compare against the data that was in the
+        # request.
+        # See GH #719
+        if isinstance(body, str) or isinstance(body, bytes) or body is None:
+            return body
+        # Based on
+        # https://github.com/urllib3/urllib3/blob/abbfbcb1dd274fc54b4f0a7785fd04d59b634195/src/urllib3/util/request.py#L220
+        if hasattr(body, "read") or isinstance(body, BufferedReader):
+            return body.read()
+        return body
+
     def _on_request(
         self,
         adapter: "HTTPAdapter",
@@ -1067,6 +1083,7 @@ class RequestsMock:
         request.params = self._parse_request_params(request.path_url)  # type: ignore[attr-defined]
         request.req_kwargs = kwargs  # type: ignore[attr-defined]
         request_url = str(request.url)
+        request.body = self._read_filelike_body(request.body)
 
         match, match_failed_reasons = self._find_match(request)
         resp_callback = self.response_callback


Test patch:
diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py
index 9088ff3..012eb90 100644
--- a/responses/tests/test_responses.py
+++ b/responses/tests/test_responses.py
@@ -1,6 +1,7 @@
 import inspect
 import os
 import re
+import tempfile
 import warnings
 from io import BufferedReader
 from io import BytesIO
@@ -2708,3 +2709,23 @@ def test_request_object_attached_to_exception():
 
     run()
     assert_reset()
+
+
+def test_file_like_body_in_request():
+    """Validate that when file-like objects are used in requests the data can be accessed
+    in the call list. This ensures that we are not storing file handles that may be closed
+    by the time the user wants to assert on the data in the request. GH #719.
+    """
+
+    @responses.activate
+    def run():
+        responses.add(responses.POST, "https://example.com")
+        with tempfile.TemporaryFile() as f:
+            f.write(b"test")
+            f.seek(0)
+            requests.post("https://example.com", data=f)
+        assert len(responses.calls) == 1
+        assert responses.calls[0].request.body == b"test"
+
+    run()
+    assert_reset()