Skip to content
Open
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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

### Fixed

- Fix regression causing `params` pattern to stop working under some conditions,
by doing a strict detection of `ANY` in multi items patterns (#313)
- Fix regression causing `params` pattern to stop working under some conditions, by
doing a strict detection of `ANY` in multi items patterns (#313)

### CI

Expand Down
5 changes: 3 additions & 2 deletions respx/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from .patterns import M, Pattern
from .types import (
DEFAULT,
CallableSideEffect,
Content,
CookieTypes,
Expand Down Expand Up @@ -397,8 +398,8 @@ def resolve(self, request: httpx.Request, **kwargs: Any) -> RouteResultTypes:
result = self._return_value

else:
# Auto mock a new response
result = httpx.Response(200, request=request)
# Auto mock a router default response
return DEFAULT

if isinstance(result, httpx.Response) and not result._request:
# Clone reused Response for immutability
Expand Down
51 changes: 42 additions & 9 deletions respx/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
Dict,
Generator,
List,
NewType,
Optional,
Tuple,
Type,
Expand All @@ -30,10 +29,15 @@
SideEffectError,
)
from .patterns import Pattern, merge_patterns, parse_url_patterns
from .types import DefaultType, ResolvedResponseTypes, RouteResultTypes, URLPatternTypes

Default = NewType("Default", object)
DEFAULT = Default(...)
from .types import (
DEFAULT,
Default,
DefaultType,
ResolvedResponseTypes,
RouteResultTypes,
SideEffectListTypes,
URLPatternTypes,
)


class Router:
Expand All @@ -43,11 +47,19 @@ def __init__(
assert_all_called: bool = True,
assert_all_mocked: bool = True,
base_url: Optional[str] = None,
default: Optional[SideEffectListTypes] = None,
) -> None:
self._assert_all_called = assert_all_called
self._assert_all_mocked = assert_all_mocked
self._bases = parse_url_patterns(base_url, exact=False)

if default is None:
self._default_route = Route().respond(200)
elif isinstance(default, httpx.Response):
self._default_route = Route().mock(return_value=default)
else:
self._default_route = Route().mock(side_effect=default)

self.routes = RouteList()
self.calls = CallList()

Expand Down Expand Up @@ -237,6 +249,11 @@ def record(
if route:
route.calls.append(call)

def default_response(self, request: httpx.Request) -> httpx.Response:
response = self._default_route.resolve(request)
assert isinstance(response, httpx.Response), "Unsupported default mock"
return response

@contextmanager
def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, None]:
resolved = ResolvedRoute()
Expand All @@ -249,8 +266,12 @@ def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, Non
if self._assert_all_mocked:
raise AllMockedAssertionError(f"RESPX: {request!r} not mocked!")

# Auto mock a successful empty response
resolved.response = httpx.Response(200)
# No route found .. Auto mock default
resolved.response = self.default_response(request)

elif resolved.response is DEFAULT:
# Route has no mocked response or side effect .. Auto mock default
resolved.response = self.default_response(request)

elif resolved.response == request:
# Pass-through request
Expand Down Expand Up @@ -282,7 +303,9 @@ def resolve(self, request: httpx.Request) -> ResolvedRoute:
resolved.response = cast(ResolvedResponseTypes, prospect)
break

if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream):
if isinstance(resolved.response, httpx.Response) and isinstance(
resolved.response.stream, httpx.ByteStream
):
resolved.response.read() # Pre-read stream

return resolved
Expand All @@ -304,7 +327,9 @@ async def aresolve(self, request: httpx.Request) -> ResolvedRoute:
resolved.response = cast(ResolvedResponseTypes, prospect)
break

if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream):
if isinstance(resolved.response, httpx.Response) and isinstance(
resolved.response.stream, httpx.ByteStream
):
await resolved.response.aread() # Pre-read stream

return resolved
Expand All @@ -327,12 +352,14 @@ def __init__(
assert_all_called: bool = True,
assert_all_mocked: bool = True,
base_url: Optional[str] = None,
default: Optional[SideEffectListTypes] = None,
using: Optional[Union[str, Default]] = DEFAULT,
) -> None:
super().__init__(
assert_all_called=assert_all_called,
assert_all_mocked=assert_all_mocked,
base_url=base_url,
default=default,
)
self.Mocker: Optional[Type[Mocker]] = None
self._using = using
Expand All @@ -345,6 +372,7 @@ def __call__(
assert_all_called: Optional[bool] = None,
assert_all_mocked: Optional[bool] = None,
base_url: Optional[str] = None,
default: Optional[SideEffectListTypes] = None,
using: Optional[Union[str, Default]] = DEFAULT,
) -> "MockRouter":
... # pragma: nocover
Expand All @@ -357,6 +385,7 @@ def __call__(
assert_all_called: Optional[bool] = None,
assert_all_mocked: Optional[bool] = None,
base_url: Optional[str] = None,
default: Optional[SideEffectListTypes] = None,
using: Optional[Union[str, Default]] = DEFAULT,
) -> Callable:
... # pragma: nocover
Expand All @@ -368,6 +397,7 @@ def __call__(
assert_all_called: Optional[bool] = None,
assert_all_mocked: Optional[bool] = None,
base_url: Optional[str] = None,
default: Optional[SideEffectListTypes] = None,
using: Optional[Union[str, Default]] = DEFAULT,
) -> Union["MockRouter", Callable]:
"""
Expand All @@ -389,6 +419,9 @@ def __call__(
settings["assert_all_called"] = assert_all_called
if assert_all_mocked is not None:
settings["assert_all_mocked"] = assert_all_mocked
if default is not None:
settings["default"] = default
settings.setdefault("assert_all_mocked", False)
respx_mock = self.__class__(**settings)
return respx_mock

Expand Down
9 changes: 8 additions & 1 deletion respx/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,19 @@

DefaultType = TypeVar("DefaultType", bound=Any)


class Default:
...


DEFAULT = Default()

URLPatternTypes = Union[str, Pattern[str], URL, httpx.URL]
QueryParamTypes = Union[
bytes, str, List[Tuple[str, Any]], Dict[str, Any], Tuple[Tuple[str, Any], ...]
]

ResolvedResponseTypes = Optional[Union[httpx.Request, httpx.Response]]
ResolvedResponseTypes = Optional[Union[httpx.Request, httpx.Response, Default]]
RouteResultTypes = Union[ResolvedResponseTypes, Awaitable[ResolvedResponseTypes]]
CallableSideEffect = Callable[..., RouteResultTypes]
SideEffectListTypes = Union[httpx.Response, Exception, Type[Exception]]
Expand Down
7 changes: 7 additions & 0 deletions tests/test_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,13 @@ async def test_configured_router_reuse(client):
assert respx.calls.call_count == 0


def test_configured_router_with_default_mock():
router = respx.mock(default=httpx.Response(404))
with router:
response = httpx.get("https://example.com/")
assert response.status_code == 404


async def test_router_return_type_misuse():
router = respx.mock(assert_all_called=False)
route = router.get("https://hot.dog/")
Expand Down
43 changes: 41 additions & 2 deletions tests/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

from respx import Route, Router
from respx.models import AllMockedAssertionError, PassThrough, RouteList
from respx.models import AllMockedAssertionError, MockResponse, PassThrough, RouteList
from respx.patterns import Host, M, Method


Expand All @@ -20,7 +20,7 @@ async def test_empty_router():
await router.aresolve(request)


async def test_empty_router__auto_mocked():
async def test_empty_router__auto_mock_success():
router = Router(assert_all_mocked=False)

request = httpx.Request("GET", "https://example.org/")
Expand All @@ -37,6 +37,45 @@ async def test_empty_router__auto_mocked():
assert resolved.response.status_code == 200


async def test_empty_router__auto_mock_default_response():
router = Router(assert_all_mocked=False, default=MockResponse(404))

request = httpx.Request("GET", "https://example.org/")
resolved = router.resolve(request)

assert resolved.route is None
assert isinstance(resolved.response, httpx.Response)
assert resolved.response.status_code == 404

resolved = await router.aresolve(request)

assert resolved.route is None
assert isinstance(resolved.response, httpx.Response)
assert resolved.response.status_code == 404


async def test_empty_router__auto_mock_default_exception():
router = Router(assert_all_mocked=False, default=httpx.NetworkError)

request = httpx.Request("GET", "https://example.org/")
with pytest.raises(httpx.NetworkError):
router.resolve(request)

with pytest.raises(httpx.NetworkError):
await router.aresolve(request)


async def test_empty_router__raises_with_default_callable_side_effect():
router = Router(
assert_all_mocked=False,
default=lambda r: r, # type: ignore[arg-type]
)

request = httpx.Request("GET", "https://example.org/")
with pytest.raises(AssertionError, match="Unsupported default mock"):
router.resolve(request)


@pytest.mark.parametrize(
("args", "kwargs", "expected"),
[
Expand Down
Loading