Repository: getsentry/responses
Pull request #664 (2023-08-17 21:32:51)

Calls info in BaseResponse
Sometimes it is more convenient to get information about the calls made directly from the mockend request, instead of calculating the order of your calls in the global list (`responses.calls[n]`).

What do you think of such a possibility?

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getsentry/responses/pull/664?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
Patch coverage: **`100.00%`** and no project coverage change.
> Comparison is base [(`ad22f54`)](https://app.codecov.io/gh/getsentry/responses/commit/ad22f54e941b95b8cde8c03e0b20a6dd1ffb4dde?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) 100.00% compared to head [(`77ca597`)](https://app.codecov.io/gh/getsentry/responses/pull/664?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) 100.00%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master      #664   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            9         9           
  Lines         2880      2945   +65     
=========================================
+ Hits          2880      2945   +65     
```


| [Files Changed](https://app.codecov.io/gh/getsentry/responses/pull/664?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Coverage Δ | |
|---|---|---|
| [responses/\_\_init\_\_.py](https://app.codecov.io/gh/getsentry/responses/pull/664?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-cmVzcG9uc2VzL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [responses/tests/test\_responses.py](https://app.codecov.io/gh/getsentry/responses/pull/664?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-cmVzcG9uc2VzL3Rlc3RzL3Rlc3RfcmVzcG9uc2VzLnB5) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getsentry/responses/pull/664?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).

zeezdev: > also, CHANGELOG entry is missing

Added the CHANGELOG entry.
markstory: This is looking good, a few small formatting issues in the README that need to be fixed up before this can be merged.
zeezdev: > This is looking good, a few small formatting issues in the README that need to be fixed up before this can be merged.

Fixed.
zeezdev: It looks like [mypy problems](https://github.com/getsentry/responses/actions/runs/6585106855/job/17907124070?pr=664#step:5:15) is related to v1.6.1 that [released](https://mypy-lang.blogspot.com/2023/10/mypy-16-released.html) several days ago.

I added several ignore comments related to my changes and old code. Please review again.



Patch:
diff --git a/CHANGES b/CHANGES
index 3113c85..626fed6 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,6 +1,7 @@
 0.24.0
 ------
 
+* Added `BaseResponse.calls` to access calls data of a separate mocked request. See #664
 * Added `real_adapter_send` parameter to `RequestsMock` that will allow users to set
   through which function they would like to send real requests
 * Added support for re.Pattern based header matching.
diff --git a/README.rst b/README.rst
index 92e0040..3ce35b5 100644
--- a/README.rst
+++ b/README.rst
@@ -1079,6 +1079,59 @@ Assert that the request was called exactly n times.
         responses.assert_call_count("http://www.example.com?hello=world", 1) is True
 
 
+Assert Request Calls data
+------------------
+
+``Request`` object has ``calls`` list which elements correspond to ``Call`` objects
+in the global list of ``Registry``. This can be useful when the order of requests is not
+guaranteed, but you need to check their correctness, for example in multithreaded
+applications.
+
+.. code-block:: python
+
+    import concurrent.futures
+    import responses
+    import requests
+
+
+    @responses.activate
+    def test_assert_calls_on_resp():
+        rsp1 = responses.patch("http://www.foo.bar/1/", status=200)
+        rsp2 = responses.patch("http://www.foo.bar/2/", status=400)
+        rsp3 = responses.patch("http://www.foo.bar/3/", status=200)
+
+        def update_user(uid, is_active):
+            url = f"http://www.foo.bar/{uid}/"
+            response = requests.patch(url, json={"is_active": is_active})
+            return response
+
+        with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
+            future_to_uid = {
+                executor.submit(update_user, uid, is_active): uid
+                for (uid, is_active) in [("3", True), ("2", True), ("1", False)]
+            }
+            for future in concurrent.futures.as_completed(future_to_uid):
+                uid = future_to_uid[future]
+                response = future.result()
+                print(f"{uid} updated with {response.status_code} status code")
+
+        assert len(responses.calls) == 3  # total calls count
+
+        assert rsp1.call_count == 1
+        assert rsp1.calls[0] in responses.calls
+        assert rsp1.calls[0].response.status_code == 200
+        assert json.loads(rsp1.calls[0].request.body) == {"is_active": False}
+
+        assert rsp2.call_count == 1
+        assert rsp2.calls[0] in responses.calls
+        assert rsp2.calls[0].response.status_code == 400
+        assert json.loads(rsp2.calls[0].request.body) == {"is_active": True}
+
+        assert rsp3.call_count == 1
+        assert rsp3.calls[0] in responses.calls
+        assert rsp3.calls[0].response.status_code == 200
+        assert json.loads(rsp3.calls[0].request.body) == {"is_active": True}
+
 Multiple Responses
 ------------------
 
diff --git a/responses/__init__.py b/responses/__init__.py
index 7394a15..0e74f9b 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -1,6 +1,8 @@
+import http
 import inspect
 import json as json_module
 import logging
+import socket
 from collections import namedtuple
 from functools import partialmethod
 from functools import wraps
@@ -85,10 +87,10 @@ if TYPE_CHECKING:  # pragma: no cover
             HTTPAdapter,
             PreparedRequest,
             bool,
-            float | tuple[float, float] | tuple[float, None] | None,
-            bool | str,
-            bytes | str | tuple[bytes | str, bytes | str] | None,
-            Mapping[str, str] | None,
+            Union[float, Tuple[float, float], Tuple[float, None], None],
+            Union[bool, str],
+            Union[bytes, str, Tuple[Union[bytes, str], Union[bytes, str]], None],
+            Optional[Mapping[str, str]],
         ],
         models.Response,
     ]
@@ -245,6 +247,9 @@ class CallList(Sequence[Any], Sized):
     def add(self, request: "PreparedRequest", response: "_Body") -> None:
         self._calls.append(Call(request, response))
 
+    def add_call(self, call: Call) -> None:
+        self._calls.append(call)
+
     def reset(self) -> None:
         self._calls = []
 
@@ -384,7 +389,7 @@ class BaseResponse:
             )
 
         self.match: "_MatcherIterable" = match
-        self.call_count: int = 0
+        self._calls: CallList = CallList()
         self.passthrough = passthrough
 
     def __eq__(self, other: Any) -> bool:
@@ -500,29 +505,39 @@ class BaseResponse:
 
         return True, ""
 
+    @property
+    def call_count(self) -> int:
+        return len(self._calls)
+
+    @property
+    def calls(self) -> CallList:
+        return self._calls
+
 
 def _form_response(
     body: Union[BufferedReader, BytesIO],
     headers: Optional[Mapping[str, str]],
     status: int,
 ) -> HTTPResponse:
-    # The requests library's cookie handling depends on the response object
-    # having an original response object with the headers as the `msg` instead
-    # of `HTTPMessage`, so we give it what it needs.
-    data = BytesIO()
-    data.close()
-
-    orig_response = HTTPResponse(
-        body=data,  # required to avoid "ValueError: Unable to determine whether fp is closed."
-        msg=headers,  # type: ignore[arg-type] # see comment above why we use headers
-        preload_content=False,
-    )
+    dummy_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    orig_response = http.client.HTTPResponse(sock=dummy_socket)
+    """
+    The cookie handling functionality of the `requests` library relies on the response object
+    having an original response object with the headers stored in the `msg` attribute.
+    Instead of supplying a file-like object of type `HTTPMessage` for the headers, we provide
+    the headers directly. This approach eliminates the need to parse the headers into a file-like
+    object and then rely on the library to unparse it back. These additional conversions can
+    introduce potential errors.
+    Therefore, we intentionally ignore type checking for this assignment.
+    """
+    orig_response.msg = headers  # type: ignore[assignment]
+
     return HTTPResponse(
         status=status,
         reason=client.responses.get(status, None),
         body=body,
         headers=headers,
-        original_response=orig_response,  # type: ignore[arg-type]
+        original_response=orig_response,
         preload_content=False,
     )
 
@@ -1048,14 +1063,16 @@ class RequestsMock:
                     request, match.get_response(request)
                 )
             except BaseException as response:
-                match.call_count += 1
-                self._calls.add(request, response)
+                call = Call(request, response)
+                self._calls.add_call(call)
+                match.calls.add_call(call)
                 raise
 
         if resp_callback:
             response = resp_callback(response)  # type: ignore[misc]
-        match.call_count += 1
-        self._calls.add(request, response)  # type: ignore[misc]
+        call = Call(request, response)  # type: ignore[misc]
+        self._calls.add_call(call)
+        match.calls.add_call(call)
 
         retries = retries or adapter.max_retries
         # first validate that current request is eligible to be retried.


Test patch:
diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py
index 2962b55..7d3af31 100644
--- a/responses/tests/test_responses.py
+++ b/responses/tests/test_responses.py
@@ -2035,6 +2035,36 @@ def test_call_count_without_matcher():
     assert_reset()
 
 
+def test_response_calls_and_registry_calls_are_equal():
+    @responses.activate
+    def run():
+        rsp1 = responses.add(responses.GET, "http://www.example.com")
+        rsp2 = responses.add(responses.GET, "http://www.example.com/1")
+        rsp3 = responses.add(
+            responses.GET, "http://www.example.com/2"
+        )  # won't be requested
+
+        requests.get("http://www.example.com")
+        requests.get("http://www.example.com/1")
+        requests.get("http://www.example.com")
+
+        assert len(responses.calls) == len(rsp1.calls) + len(rsp2.calls) + len(
+            rsp3.calls
+        )
+        assert rsp1.call_count == 2
+        assert len(rsp1.calls) == 2
+        assert rsp1.calls[0] is responses.calls[0]
+        assert rsp1.calls[1] is responses.calls[2]
+        assert rsp2.call_count == 1
+        assert len(rsp2.calls) == 1
+        assert rsp2.calls[0] is responses.calls[1]
+        assert rsp3.call_count == 0
+        assert len(rsp3.calls) == 0
+
+    run()
+    assert_reset()
+
+
 def test_fail_request_error():
     """
     Validate that exception is raised if request URL/Method/kwargs don't match