Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazy request and response decode in CallList #92

Merged
merged 7 commits into from
Oct 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()`.

Expand All @@ -327,13 +327,13 @@ 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"
assert response.status_code == 200

respx.reset()
assert len(respx.calls) == 0
assert respx.stats.call_count == 0
assert respx.calls.call_count == 0
```
2 changes: 1 addition & 1 deletion respx/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
mock = MockTransport(assert_all_called=False)

aliases = mock.aliases
stats = mock.stats
stats = mock.calls
calls: CallList = mock.calls


Expand Down
73 changes: 58 additions & 15 deletions respx/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import inspect
import re
from typing import (
TYPE_CHECKING,
Any,
AsyncIterable,
Callable,
Expand All @@ -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[
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
lundberg marked this conversation as resolved.
Show resolved Hide resolved

def match(self, request: Request) -> Optional[Union[Request, ResponseTemplate]]:
"""
Expand Down
55 changes: 25 additions & 30 deletions respx/transports.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,11 +20,10 @@
QueryParamTypes,
Request,
RequestPattern,
Response,
ResponseTemplate,
SyncResponse,
URLPatternTypes,
decode_request,
decode_response,
)


Expand All @@ -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):
"""
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ addopts =
--cov=tests
--cov-report=term-missing
--cov-report=xml
--cov-fail-under 100
-rxXs

[coverage:run]
Expand Down
10 changes: 5 additions & 5 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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
Expand Down
Loading