diff --git a/docs/api.md b/docs/api.md index 2fd4b3d..96d6ac3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -288,13 +288,13 @@ def test_something(respx_mock): response = httpx.get("https://foo.bar/") assert response.status_code == 200 - assert respx_mock.stats.call_count == 1 + assert respx_mock.calls.call_count == 1 ``` ``` python with respx.mock(assert_all_mocked=False) as respx_mock: response = httpx.get("https://foo.bar/") # OK assert response.status_code == 200 - assert respx_mock.stats.call_count == 1 + assert respx_mock.calls.call_count == 1 ``` !!! attention "Without Parentheses" @@ -304,9 +304,9 @@ with respx.mock(assert_all_mocked=False) as respx_mock: ## Call Statistics -The `respx` API includes a `.calls` list, containing captured (`request`, `response`) tuples, and a `.stats` MagicMock object with all its *bells and whistles*, i.e. `call_count`, `assert_called` etc. +The `respx` API includes a `.calls` object, containing captured (`request`, `response`) tuples and MagicMock's *bells and whistles*, i.e. `call_count`, `assert_called` etc. -Each mocked response *request pattern* has its own `.calls` and `.stats`, along with `.called` and `.call_count ` stats shortcuts. +Each mocked response *request pattern* has its own `.calls`, along with `.called` and `.call_count ` stats shortcuts. To reset stats without stop mocking, use `respx.reset()`. @@ -327,7 +327,7 @@ def test_something(): assert respx.aliases["index"].called assert respx.aliases["index"].call_count == 1 - assert respx.stats.call_count == 2 + assert respx.calls.call_count == 2 request, response = respx.calls[-1] assert request.method == "GET" @@ -335,5 +335,5 @@ def test_something(): respx.reset() assert len(respx.calls) == 0 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 ``` diff --git a/respx/api.py b/respx/api.py index 2ca202f..c2fdfcc 100644 --- a/respx/api.py +++ b/respx/api.py @@ -15,7 +15,7 @@ mock = MockTransport(assert_all_called=False) aliases = mock.aliases -stats = mock.stats +stats = mock.calls calls: CallList = mock.calls diff --git a/respx/models.py b/respx/models.py index c3ff082..ca0db33 100644 --- a/respx/models.py +++ b/respx/models.py @@ -1,7 +1,6 @@ import inspect import re from typing import ( - TYPE_CHECKING, Any, AsyncIterable, Callable, @@ -19,14 +18,11 @@ ) from unittest import mock from urllib.parse import urljoin +from warnings import warn import httpx from httpcore import AsyncByteStream, SyncByteStream -if TYPE_CHECKING: - from unittest.mock import _CallList # pragma: nocover - - URL = Tuple[bytes, bytes, Optional[int], bytes] Headers = List[Tuple[bytes, bytes]] Request = Tuple[ @@ -135,18 +131,61 @@ class Call(NamedTuple): response: Optional[httpx.Response] -class CallList(list): +class RawCall: + def __init__(self, raw_request: Request, raw_response: Optional[Response] = None): + self.raw_request = raw_request + self.raw_response = raw_response + + self._call: Optional[Call] = None + + @property + def call(self) -> Call: + if self._call is None: + self._call = self._decode_call() + + return self._call + + def _decode_call(self) -> Call: + # Decode raw request/response as HTTPX models + request = decode_request(self.raw_request) + response = decode_response(self.raw_response, request=request) + + # Pre-read request/response, but only if mocked, not for pass-through streams + if response and not isinstance( + response.stream, (SyncByteStream, AsyncByteStream) + ): + request.read() + response.read() + + return Call(request=request, response=response) + + +class CallList(list, mock.NonCallableMock): def __iter__(self) -> Generator[Call, None, None]: - yield from super().__iter__() + for raw_call in super().__iter__(): + yield raw_call.call + + def __getitem__(self, item: int) -> Call: # type: ignore + raw_call: RawCall = super().__getitem__(item) + return raw_call.call + + @property + def called(self) -> bool: # type: ignore + return bool(self) - @classmethod - def from_unittest_call_list(cls, call_list: "_CallList") -> "CallList": - return cls(Call(request, response) for (request, response), _ in call_list) + @property + def call_count(self) -> int: # type: ignore + return len(self) @property def last(self) -> Optional[Call]: return self[-1] if self else None + def record(self, raw_request: Request, raw_response: Response) -> RawCall: + raw_call = RawCall(raw_request=raw_request, raw_response=raw_response) + self.append(raw_call) + return raw_call + class ResponseTemplate: _content: Optional[ContentDataTypes] @@ -343,19 +382,23 @@ def __init__( self.response = response or ResponseTemplate() self.alias = alias - self.stats = mock.MagicMock() + self.calls = CallList() @property def called(self) -> bool: - return self.stats.called + return self.calls.called @property def call_count(self) -> int: - return self.stats.call_count + return self.calls.call_count @property - def calls(self) -> CallList: - return CallList.from_unittest_call_list(self.stats.call_args_list) + def stats(self): + warn( + ".stats property is deprecated. Please, use .calls", + category=DeprecationWarning, + ) + return self.calls def match(self, request: Request) -> Optional[Union[Request, ResponseTemplate]]: """ diff --git a/respx/transports.py b/respx/transports.py index a0f2882..4611974 100644 --- a/respx/transports.py +++ b/respx/transports.py @@ -1,5 +1,5 @@ -from typing import Any, Callable, Dict, List, Optional, Tuple, Union, overload -from unittest import mock +from typing import Callable, Dict, List, Optional, Tuple, Union, overload +from warnings import warn from httpcore import ( AsyncByteStream, @@ -20,11 +20,10 @@ QueryParamTypes, Request, RequestPattern, + Response, ResponseTemplate, SyncResponse, URLPatternTypes, - decode_request, - decode_response, ) @@ -42,8 +41,7 @@ def __init__( self.patterns: List[RequestPattern] = [] self.aliases: Dict[str, RequestPattern] = {} - self.stats = mock.MagicMock() - self.calls: CallList = CallList() + self.calls = CallList() def clear(self): """ @@ -57,9 +55,17 @@ def reset(self) -> None: Resets call stats. """ self.calls.clear() - self.stats.reset_mock() + for pattern in self.patterns: - pattern.stats.reset_mock() + pattern.calls.clear() + + @property + def stats(self): + warn( + ".stats property is deprecated. Please, use .calls", + category=DeprecationWarning, + ) + return self.calls def __getitem__(self, alias: str) -> Optional[RequestPattern]: return self.aliases.get(alias) @@ -349,29 +355,14 @@ def options( def record( self, - request: Any, - response: Optional[Any], + request: Request, + response: Optional[Response], pattern: Optional[RequestPattern] = None, ) -> None: - # Decode raw request/response as HTTPX models - request = decode_request(request) - response = decode_response(response, request=request) - # TODO: Skip recording stats for pass_through requests? - # Pre-read request/response, but only if mocked, not for pass-through streams - if response and not isinstance( - response.stream, (SyncByteStream, AsyncByteStream) - ): - request.read() - response.read() - + call = self.calls.record(request, response) if pattern: - pattern.stats(request, response) - - self.stats(request, response) - - # Copy stats due to unwanted use of property refs in the high-level api - self.calls[:] = CallList.from_unittest_call_list(self.stats.call_args_list) + pattern.calls.append(call) def assert_all_called(self) -> None: assert all( @@ -422,7 +413,7 @@ def match( # Assert we always get a pattern match, if check is enabled assert not self._assert_all_mocked, f"RESPX: {request[1]!r} not mocked!" - # Auto mock a successfull empty response + # Auto mock a successful empty response response = ResponseTemplate() return matched_pattern, request, response @@ -451,7 +442,9 @@ def request( response = None raise finally: - self.record(request, response, pattern=pattern) + self.record( + request, response, pattern=pattern + ) # pragma: nocover # python 3.9 bug async def arequest( self, @@ -477,7 +470,9 @@ async def arequest( response = None raise finally: - self.record(request, response, pattern=pattern) + self.record( + request, response, pattern=pattern + ) # pragma: nocover # python 3.9 bug def close(self) -> None: if self._assert_all_called: diff --git a/setup.cfg b/setup.cfg index ed047df..9eb55d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,6 +22,7 @@ addopts = --cov=tests --cov-report=term-missing --cov-report=xml + --cov-fail-under 100 -rxXs [coverage:run] diff --git a/tests/test_api.py b/tests/test_api.py index fed7fb2..3463738 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -61,7 +61,7 @@ async def test_http_methods(client): assert response.status_code == 501 assert m.called is True - assert respx.stats.call_count == 7 * 2 + assert respx.calls.call_count == 7 * 2 @pytest.mark.asyncio @@ -106,16 +106,16 @@ async def test_repeated_pattern(client): assert response1.status_code == 201 assert response2.status_code == 409 assert response3.status_code == 409 - assert respx_mock.stats.call_count == 3 + assert respx_mock.calls.call_count == 3 assert one.called is True assert one.call_count == 1 - statuses = [response.status_code for _, response in one.calls] + statuses = [call.response.status_code for call in one.calls] assert statuses == [201] assert two.called is True assert two.call_count == 2 - statuses = [response.status_code for _, response in two.calls] + statuses = [call.response.status_code for call in two.calls] assert statuses == [409, 409] @@ -408,7 +408,7 @@ async def content(request, page): assert response_one.text == "one" assert response_two.text == "two" - assert respx.stats.call_count == 2 + assert respx.calls.call_count == 2 @pytest.mark.asyncio diff --git a/tests/test_mock.py b/tests/test_mock.py index 85003ba..618e3f1 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -11,41 +11,43 @@ @pytest.mark.asyncio @respx.mock async def test_decorating_test(client): - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 + respx.calls.assert_not_called() request = respx.get("https://foo.bar/", status_code=202) response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 + respx.calls.assert_called_once() @pytest.mark.asyncio async def test_mock_request_fixture(client, my_mock): - assert respx.stats.call_count == 0 - assert my_mock.stats.call_count == 0 + assert respx.calls.call_count == 0 + assert my_mock.calls.call_count == 0 response = await client.get("https://httpx.mock/") request = my_mock.aliases["index"] assert request.called is True assert response.is_error assert response.status_code == 404 - assert respx.stats.call_count == 0 - assert my_mock.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert my_mock.calls.call_count == 1 @pytest.mark.asyncio async def test_mock_single_session_fixture(client, mocked_foo): - current_foo_call_count = mocked_foo.stats.call_count + current_foo_call_count = mocked_foo.calls.call_count response = await client.get("https://foo.api/bar/") request = mocked_foo.aliases["bar"] assert request.called is True assert response.status_code == 200 - assert mocked_foo.stats.call_count == current_foo_call_count + 1 + assert mocked_foo.calls.call_count == current_foo_call_count + 1 @pytest.mark.asyncio async def test_mock_multiple_session_fixtures(client, mocked_foo, mocked_ham): - current_foo_call_count = mocked_foo.stats.call_count - current_ham_call_count = mocked_ham.stats.call_count + current_foo_call_count = mocked_foo.calls.call_count + current_ham_call_count = mocked_ham.calls.call_count response = await client.get("https://foo.api/") request = mocked_foo.aliases["index"] @@ -57,115 +59,115 @@ async def test_mock_multiple_session_fixtures(client, mocked_foo, mocked_ham): assert request.called is True assert response.status_code == 200 - assert mocked_foo.stats.call_count == current_foo_call_count + 1 - assert mocked_ham.stats.call_count == current_ham_call_count + 1 + assert mocked_foo.calls.call_count == current_foo_call_count + 1 + assert mocked_ham.calls.call_count == current_ham_call_count + 1 def test_global_sync_decorator(): @respx.mock def test(): - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 request = respx.get("https://foo.bar/", status_code=202) response = httpx.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 test() - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 @pytest.mark.asyncio async def test_global_async_decorator(client): @respx.mock async def test(): - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 request = respx.get("https://foo.bar/", status_code=202) response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 await test() - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 def test_local_sync_decorator(): @respx.mock() def test(respx_mock): - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 request = respx_mock.get("https://foo.bar/", status_code=202) response = httpx.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 0 - assert respx_mock.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert respx_mock.calls.call_count == 1 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 test() - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 @pytest.mark.asyncio async def test_local_async_decorator(client): @respx.mock() async def test(respx_mock): - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 request = respx_mock.get("https://foo.bar/", status_code=202) response = await client.get("https://foo.bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 0 - assert respx_mock.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert respx_mock.calls.call_count == 1 await test() - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 @pytest.mark.asyncio async def test_global_contextmanager(client): with respx.mock: - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 request = respx.get("https://foo/bar/", status_code=202) response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 async with respx.mock: - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 request = respx.get("https://foo/bar/", status_code=202) response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 @pytest.mark.asyncio async def test_local_contextmanager(client): with respx.mock() as respx_mock: - assert respx_mock.stats.call_count == 0 + assert respx_mock.calls.call_count == 0 request = respx_mock.get("https://foo/bar/", status_code=202) response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 0 - assert respx_mock.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert respx_mock.calls.call_count == 1 async with respx.mock() as respx_mock: - assert respx_mock.stats.call_count == 0 + assert respx_mock.calls.call_count == 0 request = respx_mock.get("https://foo/bar/", status_code=202) response = await client.get("https://foo/bar/") assert request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 0 - assert respx_mock.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert respx_mock.calls.call_count == 1 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 @pytest.mark.asyncio @@ -179,16 +181,16 @@ async def test_nested_local_contextmanager(client): response = await client.get("https://foo/bar/") assert get_request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 0 - assert respx_mock_1.stats.call_count == 1 - assert respx_mock_2.stats.call_count == 0 + assert respx.calls.call_count == 0 + assert respx_mock_1.calls.call_count == 1 + assert respx_mock_2.calls.call_count == 0 response = await client.post("https://foo/bar/") assert post_request.called is True assert response.status_code == 201 - assert respx.stats.call_count == 0 - assert respx_mock_1.stats.call_count == 1 - assert respx_mock_2.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert respx_mock_1.calls.call_count == 1 + assert respx_mock_2.calls.call_count == 1 @pytest.mark.asyncio @@ -202,19 +204,19 @@ async def test_nested_global_contextmanager(client): response = await client.get("https://foo/bar/") assert get_request.called is True assert response.status_code == 202 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 response = await client.post("https://foo/bar/") assert post_request.called is True assert response.status_code == 201 - assert respx.stats.call_count == 2 + assert respx.calls.call_count == 2 @pytest.mark.asyncio async def test_configured_decorator(client): @respx.mock(assert_all_called=False, assert_all_mocked=False) async def test(respx_mock): - assert respx_mock.stats.call_count == 0 + assert respx_mock.calls.call_count == 0 request = respx_mock.get("https://foo.bar/") response = await client.get("https://some.thing/") @@ -223,8 +225,8 @@ async def test(respx_mock): assert response.text == "" assert request.called is False - assert respx.stats.call_count == 0 - assert respx_mock.stats.call_count == 1 + assert respx.calls.call_count == 0 + assert respx_mock.calls.call_count == 1 _request, _response = respx_mock.calls.last assert _request is not None @@ -234,7 +236,7 @@ async def test(respx_mock): assert _request.url == "https://some.thing/" await test() - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 @pytest.mark.asyncio @@ -268,7 +270,7 @@ async def test_base_url(respx_mock=None): async def test_start_stop(client): url = "https://foo.bar/" request = respx.add("GET", url, status_code=202) - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 try: respx.start() @@ -276,16 +278,16 @@ async def test_start_stop(client): assert request.called is True assert response.status_code == 202 assert response.text == "" - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 respx.stop(clear=False, reset=False) assert len(respx.mock.patterns) == 1 - assert respx.stats.call_count == 1 + assert respx.calls.call_count == 1 assert request.called is True respx.reset() assert len(respx.mock.patterns) == 1 - assert respx.stats.call_count == 0 + assert respx.calls.call_count == 0 assert request.called is False respx.clear() @@ -329,14 +331,14 @@ async def test_assert_all_mocked(client, assert_all_mocked, raises): with raises: with MockTransport(assert_all_mocked=assert_all_mocked) as respx_mock: response = httpx.get("https://foo.bar/") - assert respx_mock.stats.call_count == 1 + assert respx_mock.calls.call_count == 1 assert response.status_code == 200 with raises: async with MockTransport(assert_all_mocked=assert_all_mocked) as respx_mock: response = await client.get("https://foo.bar/") - assert respx_mock.stats.call_count == 1 + assert respx_mock.calls.call_count == 1 assert response.status_code == 200 - assert respx_mock.stats.call_count == 0 + assert respx_mock.calls.call_count == 0 @pytest.mark.asyncio diff --git a/tests/test_stats.py b/tests/test_stats.py index 1b5ca3f..13039d2 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,5 +1,6 @@ import asyncio import re +import warnings import httpx import pytest @@ -37,8 +38,8 @@ async def test(backend): assert foobar1.call_count == len(foobar1.calls) assert foobar1.call_count == 0 assert foobar1.calls.last is None - assert respx.stats.call_count == len(respx.calls) - assert respx.stats.call_count == 0 + assert respx.calls.call_count == len(respx.calls) + assert respx.calls.call_count == 0 async with httpx.AsyncClient() as client: get_response = await client.get(url) @@ -48,6 +49,9 @@ async def test(backend): assert foobar2.called is True assert foobar1.call_count == 1 assert foobar2.call_count == 1 + with warnings.catch_warnings(record=True) as w: + assert foobar1.stats.call_count == 1 + assert len(w) == 1 _request, _response = foobar1.calls[-1] assert isinstance(_request, httpx.Request) @@ -69,10 +73,17 @@ async def test(backend): assert _response.content == del_response.content == b"del" assert id(_response) != id(del_response) # TODO: Fix this? - assert respx.stats.call_count == 2 + assert respx.calls.call_count == 2 assert respx.calls[0] == foobar1.calls[-1] assert respx.calls[1] == foobar2.calls[-1] + with warnings.catch_warnings(record=True) as w: + assert respx.mock.stats.call_count == 2 + assert len(w) == 1 + + assert respx.stats.call_count == 2 + assert len(w) == 1 + alias = respx.aliases["get_foobar"] assert alias == foobar1 assert alias.alias == foobar1.alias