Repository: lundberg/respx

Issue:
ResponseNotRead when attempting to access response data through respx.calls
I'm working on mocking an api client I've written using httpx which manipulates the data before returning it, and I can't seem to get the raw content from the request (before the manipulation). I've reconstructed what the client does, with cookies, headers, etc, to demonstrate the issue still happens with httpbin.org.

As you can see, the `request.content` is read (it's none, so an empty byte-string is expected), but the response is readable from httpx, but not respx.

Due to the api client construction, I don't immediately have access to the raw request (i.e. `httpx.get(...)` or `client.get(...)`), and I was hoping the mock would store the content in a retrievable format when passing through.

Note: Although not demonstrated here, this also happens with `text` and `json()`.

Am I doing something wrong or are my expectations misplaced?

Versions:
- respx (07ca910)
- httpx (0.7.6)

Demo:
```py
class TestGetContent:
    @respx.mock
    def test_get_content(self, mc_client):
        respx.get("https://httpbin.org/json", pass_through=True)
        base_url = httpx.URL("https://httpbin.org")
        cookies = httpx.Cookies()
        cookies.set(
            name="a_cookie", value="value", domain="httpbin.org",
        )
        headers = httpx.Headers(
            {"User-Agent": "my-python-client/0.0.7.dev0", "X-Auth": "auth_value"}
        )
        timeout = httpx.config.TimeoutConfig(timeout=300)
        c = httpx.Client(base_url=base_url, cookies=cookies, headers=headers, timeout=timeout)
        response = c.get("json")
        print(f"response.text={response.text}\n===")
        print(f"response.json()={response.json()}\n===")
        print(f"response.content={response.content}\n===")
        for req, resp in respx.calls:
            print(f"respx request.content={req.content}\n---")
            print(f"respx response.content={resp.content}\n---")
```

PyTest Result:
```py
>           print(f"respx response.content={resp.content}\n---")

tests/test_content.py:58: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Response [200 OK]>

    @property
    def content(self) -> bytes:
        if not hasattr(self, "_content"):
            if hasattr(self, "_raw_content"):
                raw_content = self._raw_content  # type: ignore
                content = self.decoder.decode(raw_content)
                content += self.decoder.flush()
                self._content = content
            else:
>               raise ResponseNotRead()
E               httpx.exceptions.ResponseNotRead

../../../Library/Caches/pypoetry/virtualenvs/test-py3.7/lib/python3.7/site-packages/httpx/models.py:816: ResponseNotRead
--------------------------------------------------------------------- Captured stdout call ---------------------------------------------------------------------
response.text={
  "slideshow": {
    "author": "Yours Truly", 
    "date": "date of publication", 
    "slides": [
      {
        "title": "Wake up to WonderWidgets!", 
        "type": "all"
      }, 
      {
        "items": [
          "Why <em>WonderWidgets</em> are great", 
          "Who <em>buys</em> WonderWidgets"
        ], 
        "title": "Overview", 
        "type": "all"
      }
    ], 
    "title": "Sample Slide Show"
  }
}

===
response.json()={'slideshow': {'author': 'Yours Truly', 'date': 'date of publication', 'slides': [{'title': 'Wake up to WonderWidgets!', 'type': 'all'}, {'items': ['Why <em>WonderWidgets</em> are great', 'Who <em>buys</em> WonderWidgets'], 'title': 'Overview', 'type': 'all'}], 'title': 'Sample Slide Show'}}
===
response.content=b'{\n  "slideshow": {\n    "author": "Yours Truly", \n    "date": "date of publication", \n    "slides": [\n      {\n        "title": "Wake up to WonderWidgets!", \n        "type": "all"\n      }, \n      {\n        "items": [\n          "Why <em>WonderWidgets</em> are great", \n          "Who <em>buys</em> WonderWidgets"\n        ], \n        "title": "Overview", \n        "type": "all"\n      }\n    ], \n    "title": "Sample Slide Show"\n  }\n}\n'
===
respx request.content=b''
---
```

Discussion:

Patch:
diff --git a/respx/mock.py b/respx/mock.py
index fd62371..4daa0f3 100644
--- a/respx/mock.py
+++ b/respx/mock.py
@@ -2,13 +2,21 @@ import io
 import ssl
 import typing
 from contextlib import contextmanager
-from functools import partialmethod, wraps
+from functools import partial, partialmethod, wraps
 
 import asynctest
+from httpx import AsyncClient, Client
 from httpx.client import BaseClient
 from httpx.concurrency.base import ConcurrencyBackend
 from httpx.config import TimeoutConfig
-from httpx.models import AsyncRequest, AsyncResponse, Headers, HeaderTypes
+from httpx.models import (
+    AsyncRequest,
+    AsyncResponse,
+    BaseResponse,
+    Headers,
+    HeaderTypes,
+    Response,
+)
 
 from .models import ContentDataTypes, RequestPattern, ResponseTemplate, URLResponse
 
@@ -19,6 +27,8 @@ except ImportError:  # pragma: nocover
     from httpx import BaseTCPStream as BaseSocketStream
 
 _get_response = BaseClient._get_response  # Pass-through reference
+_send = Client.send  # Pass-through reference
+_async_send = AsyncClient.send  # Pass-through reference
 
 __all__ = ["HTTPXMock"]
 
@@ -33,7 +43,7 @@ class HTTPXMock:
         self._patterns: typing.List[RequestPattern] = []
         self.aliases: typing.Dict[str, RequestPattern] = {}
         self.calls: typing.List[
-            typing.Tuple[AsyncRequest, typing.Optional[AsyncResponse]]
+            typing.Tuple[AsyncRequest, typing.Optional[BaseResponse]]
         ] = []
 
     def clear(self):
@@ -68,22 +78,27 @@ class HTTPXMock:
         """
         Starts mocking httpx.
         """
-        # Unbound -> bound spy version of client._get_response
-        async def unbound_get_response(
-            client: BaseClient, request: AsyncRequest, **kwargs: typing.Any
+        # Unbound -> bound spy version of AsyncClient.send
+        async def unbound_async_send(
+            client: AsyncClient, request: AsyncRequest, **kwargs: typing.Any
         ) -> AsyncResponse:
-            return await self._get_response_spy(client, request, **kwargs)
+            return await self._async_send_spy(client, request, **kwargs)
 
-        # Spy on client._get_response
-        self._patchers.append(
-            asynctest.mock.patch(
-                "httpx.client.BaseClient._get_response", new=unbound_get_response
-            )
-        )
+        # Unbound -> bound spy version of Client.send
+        def unbound_send(
+            client: Client, request: AsyncRequest, **kwargs: typing.Any
+        ) -> Response:
+            return self._sync_send_spy(client, request, **kwargs)
 
         # Start patching
-        for patcher in self._patchers:
+        mockers = (
+            ("httpx.client.AsyncClient.send", unbound_async_send),
+            ("httpx.client.Client.send", unbound_send),
+        )
+        for target, mocker in mockers:
+            patcher = asynctest.mock.patch(target, new=mocker)
             patcher.start()
+            self._patchers.append(patcher)
 
     def stop(self) -> None:
         """
@@ -173,7 +188,7 @@ class HTTPXMock:
                 raise ValueError(
                     (
                         "Matched request pattern must return either a "
-                        'ResponseTemplate or an AsyncResponse, got "{}"'
+                        'ResponseTemplate or an AsyncRequest, got "{}"'
                     ).format(type(match))
                 )
 
@@ -189,17 +204,31 @@ class HTTPXMock:
 
         return matched_pattern, response
 
-    @contextmanager
-    def _patch_backend(
+    def _capture(
         self,
-        backend: ConcurrencyBackend,
         request: AsyncRequest,
-        response: typing.Optional[ResponseTemplate],
-    ) -> typing.Iterator[None]:
+        response: typing.Optional[BaseResponse],
+        pattern: typing.Optional[RequestPattern] = None,
+    ) -> None:
+        """
+        Captures request and response calls for statistics.
+        """
+        if pattern:
+            pattern(request, response)
+
+        self.calls.append((request, response))
+
+    @contextmanager
+    def _patch_backend(
+        self, backend: ConcurrencyBackend, request: AsyncRequest,
+    ) -> typing.Iterator[typing.Callable]:
         patchers = []
 
+        # 1. Match request against added patterns
+        pattern, response = self._match(request)
+
         if response is not None:
-            # 1. Patch request url with response for later pickup in patched backend
+            # 2. Patch request url with response for later pickup in patched backend
             request.url = URLResponse(request.url, response)
 
             # Mock open_tcp_stream()
@@ -218,41 +247,50 @@ class HTTPXMock:
                     )
                 )
 
-        # 2. Start patchers
-        for patcher in patchers:
-            patcher.start()
+            # 3. Start patchers
+            for patcher in patchers:
+                patcher.start()
 
         try:
-            yield
+            yield partial(self._capture, pattern=pattern)
         finally:
-            # 3. Stop patchers
+            # 4. Stop patchers
             for patcher in patchers:
-                patcher.start()
+                patcher.stop()
 
-    async def _get_response_spy(
-        self, client: BaseClient, request: AsyncRequest, **kwargs: typing.Any
+    async def _async_send_spy(
+        self, client: AsyncClient, request: AsyncRequest, **kwargs: typing.Any
     ) -> AsyncResponse:
         """
-        Spy method for BaseClient._get_response().
+        Spy for async AsyncClient.send().
 
         Patches request.url and attaches matched response template,
         and mocks client backend open stream methods.
         """
-        # 1. Match request against added patterns
-        pattern, _response = self._match(request)
-
-        # 2. Patch client's backend and continue to original _get_response
-        try:
-            with self._patch_backend(client.concurrency_backend, request, _response):
+        with self._patch_backend(client.concurrency_backend, request) as capture:
+            try:
                 response = None
-                response = await _get_response(client, request, **kwargs)
-        finally:
-            # 3. Update stats
-            if pattern:
-                pattern(request, response)
-            self.calls.append((request, response))
+                response = await _async_send(client, request, **kwargs)
+                return response
+            finally:
+                capture(request, response)
+
+    def _sync_send_spy(
+        self, client: Client, request: AsyncRequest, **kwargs: typing.Any
+    ) -> Response:
+        """
+        Spy for sync Client.send().
 
-        return response
+        Patches request.url and attaches matched response template,
+        and mocks client backend open stream methods.
+        """
+        with self._patch_backend(client.concurrency_backend, request) as capture:
+            try:
+                response = None
+                response = _send(client, request, **kwargs)
+                return response
+            finally:
+                capture(request, response)
 
     async def _open_tcp_stream_mock(
         self,
diff --git a/respx/models.py b/respx/models.py
index 5131247..df82e6a 100644
--- a/respx/models.py
+++ b/respx/models.py
@@ -4,7 +4,7 @@ import typing
 from functools import partial
 from unittest import mock
 
-from httpx.models import URL, AsyncRequest, AsyncResponse, Headers, HeaderTypes
+from httpx.models import URL, AsyncRequest, BaseResponse, Headers, HeaderTypes
 
 Regex = type(re.compile(""))
 Kwargs = typing.Dict[str, typing.Any]
@@ -105,7 +105,7 @@ class RequestPattern:
         ]
 
     def __call__(
-        self, request: AsyncRequest, response: typing.Optional[AsyncResponse]
+        self, request: AsyncRequest, response: typing.Optional[BaseResponse]
     ) -> None:
         self._stats(request, response)
 


Test patch:
diff --git a/tests/test_mock.py b/tests/test_mock.py
index 97fd913..7218de5 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -410,7 +410,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertTrue(request.called)
             self.assertIsNone(request.pass_through)
 
-    async def test_stats(self, backend=None):
+    async def test_async_stats(self, backend=None):
         with respx.mock():
             url = "https://foo/bar/1/"
             respx.get(re.compile("http://some/url"))
@@ -433,20 +433,24 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertEqual(len(foobar2.calls), 1)
 
             _request, _response = foobar1.calls[-1]
-            self.assertIsNotNone(_request)
-            self.assertIsNotNone(_response)
+            self.assertIsInstance(_request, httpx.AsyncRequest)
+            self.assertIsInstance(_response, httpx.AsyncResponse)
             self.assertEqual(_request.method, "GET")
             self.assertEqual(_request.url, url)
             self.assertEqual(_response.status_code, 202)
             self.assertEqual(_response.status_code, get_response.status_code)
+            self.assertEqual(_response.content, get_response.content)
+            self.assertEqual(id(_response), id(get_response))
 
             _request, _response = foobar2.calls[-1]
-            self.assertIsNotNone(_request)
-            self.assertIsNotNone(_response)
+            self.assertIsInstance(_request, httpx.AsyncRequest)
+            self.assertIsInstance(_response, httpx.AsyncResponse)
             self.assertEqual(_request.method, "DELETE")
             self.assertEqual(_request.url, url)
             self.assertEqual(_response.status_code, 200)
             self.assertEqual(_response.status_code, del_response.status_code)
+            self.assertEqual(_response.content, del_response.content)
+            self.assertEqual(id(_response), id(del_response))
 
             self.assertEqual(len(respx.calls), 2)
             self.assertEqual(respx.calls[0], foobar1.calls[-1])
@@ -460,5 +464,37 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertEqual(alias, foobar2)
             self.assertEqual(alias.alias, foobar2.alias)
 
+    def test_sync_stats(self, backend=None):
+        with respx.mock():
+            url = "https://foo/bar/1/"
+            foobar1 = respx.get(url, status_code=202, alias="get_foobar")
+            foobar2 = respx.delete(url, status_code=200, alias="del_foobar")
+
+            with httpx.Client(backend=backend) as client:
+                get_response = client.get(url)
+                del_response = client.delete(url)
+
+            self.assertTrue(foobar1.called)
+            self.assertTrue(foobar2.called)
+            self.assertEqual(len(foobar1.calls), 1)
+            self.assertEqual(len(foobar2.calls), 1)
+            self.assertEqual(len(respx.calls), 2)
+            self.assertEqual(respx.calls[0], foobar1.calls[-1])
+            self.assertEqual(respx.calls[1], foobar2.calls[-1])
+
+            _request, _response = foobar1.calls[-1]
+            self.assertIsInstance(_request, httpx.AsyncRequest)
+            self.assertIsInstance(_response, httpx.Response)
+            self.assertEqual(_response.content, get_response.content)
+            self.assertEqual(id(_response), id(get_response))
+            self.assertEqual(id(_response.request), id(_request))
+
+            _request, _response = foobar2.calls[-1]
+            self.assertIsInstance(_request, httpx.AsyncRequest)
+            self.assertIsInstance(_response, httpx.Response)
+            self.assertEqual(_response.content, del_response.content)
+            self.assertEqual(id(_response), id(del_response))
+            self.assertEqual(id(_response.request), id(_request))
+
     def test_trio_backend(self):
-        trio.run(self.test_stats, TrioBackend())
+        trio.run(self.test_async_stats, TrioBackend())