Skip to content

Commit 76b5b1a

Browse files
committed
replace respx with httpserver
1 parent 6e6fd5e commit 76b5b1a

File tree

6 files changed

+235
-135
lines changed

6 files changed

+235
-135
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,15 @@ dev = [
7171
"pydoc-markdown~=4.8.0",
7272
"pytest-asyncio~=1.1.0",
7373
"pytest-cov~=6.2.0",
74+
"pytest-httpserver>=1.1.3",
7475
"pytest-timeout>=2.4.0",
7576
"pytest-xdist~=3.8.0",
7677
"pytest~=8.4.0",
77-
"respx~=0.22.0",
7878
"ruff~=0.12.0",
7979
"setuptools", # setuptools are used by pytest but not explicitly required
8080
"uvicorn[standard]",
81+
"werkzeug~=3.0.0", # Werkzeug is used by httpserver
82+
"yarl~=1.20.0", # yarl is used by crawlee
8183
]
8284

8385
[tool.hatch.build.targets.wheel]

tests/unit/actor/test_actor_create_proxy_configuration.py

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from typing import TYPE_CHECKING, Any
4+
from unittest.mock import Mock
45

56
import httpx
67
import pytest
@@ -11,7 +12,10 @@
1112
from apify import Actor
1213

1314
if TYPE_CHECKING:
14-
from respx import MockRouter
15+
from collections.abc import Iterator
16+
17+
from pytest_httpserver import HTTPServer
18+
from werkzeug import Request, Response
1519

1620
from ..conftest import ApifyClientAsyncPatcher
1721

@@ -24,25 +28,43 @@ def patched_apify_client(apify_client_async_patcher: ApifyClientAsyncPatcher) ->
2428
return ApifyClientAsync()
2529

2630

31+
@pytest.fixture
32+
def patched_httpx_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
33+
"""Patch httpx client to avoid actual network calls."""
34+
35+
class ProxylessAsyncClient(httpx.AsyncClient):
36+
def __init__(self, *args: Any, **kwargs: Any) -> None:
37+
kwargs.pop('proxy', None)
38+
super().__init__(*args, **kwargs)
39+
40+
monkeypatch.setattr(httpx, 'AsyncClient', ProxylessAsyncClient)
41+
yield
42+
monkeypatch.undo()
43+
44+
45+
@pytest.mark.usefixtures('patched_httpx_client')
2746
async def test_basic_proxy_configuration_creation(
2847
monkeypatch: pytest.MonkeyPatch,
29-
respx_mock: MockRouter,
48+
httpserver: HTTPServer,
3049
patched_apify_client: ApifyClientAsync,
3150
) -> None:
32-
dummy_proxy_status_url = 'http://dummy-proxy-status-url.com'
51+
dummy_proxy_status_url = str(httpserver.url_for('/')).removesuffix('/')
3352
monkeypatch.setenv(ApifyEnvVars.TOKEN.value, 'DUMMY_TOKEN')
3453
monkeypatch.setenv(ApifyEnvVars.PROXY_STATUS_URL.value, dummy_proxy_status_url)
3554

36-
route = respx_mock.get(dummy_proxy_status_url)
37-
route.mock(
38-
httpx.Response(
39-
200,
40-
json={
41-
'connected': True,
42-
'connectionError': None,
43-
'isManInTheMiddle': True,
44-
},
45-
)
55+
call_mock = Mock()
56+
57+
def request_handler(request: Request, response: Response) -> Response:
58+
call_mock(request.url)
59+
return response
60+
61+
httpserver.expect_oneshot_request('/').with_post_hook(request_handler).respond_with_json(
62+
{
63+
'connected': True,
64+
'connectionError': None,
65+
'isManInTheMiddle': True,
66+
},
67+
status=200,
4668
)
4769

4870
groups = ['GROUP1', 'GROUP2']
@@ -58,32 +80,36 @@ async def test_basic_proxy_configuration_creation(
5880
assert proxy_configuration._country_code == country_code
5981

6082
assert len(patched_apify_client.calls['user']['get']) == 1 # type: ignore[attr-defined]
61-
assert len(route.calls) == 1
83+
assert call_mock.call_count == 1
6284

6385
await Actor.exit()
6486

6587

88+
@pytest.mark.usefixtures('patched_httpx_client')
6689
async def test_proxy_configuration_with_actor_proxy_input(
6790
monkeypatch: pytest.MonkeyPatch,
68-
respx_mock: MockRouter,
91+
httpserver: HTTPServer,
6992
patched_apify_client: ApifyClientAsync,
7093
) -> None:
71-
dummy_proxy_status_url = 'http://dummy-proxy-status-url.com'
94+
dummy_proxy_status_url = str(httpserver.url_for('/')).removesuffix('/')
7295
dummy_proxy_url = 'http://dummy-proxy.com:8000'
7396

7497
monkeypatch.setenv(ApifyEnvVars.TOKEN.value, 'DUMMY_TOKEN')
7598
monkeypatch.setenv(ApifyEnvVars.PROXY_STATUS_URL.value, dummy_proxy_status_url)
7699

77-
route = respx_mock.get(dummy_proxy_status_url)
78-
route.mock(
79-
httpx.Response(
80-
200,
81-
json={
82-
'connected': True,
83-
'connectionError': None,
84-
'isManInTheMiddle': True,
85-
},
86-
)
100+
call_mock = Mock()
101+
102+
def request_handler(request: Request, response: Response) -> Response:
103+
call_mock(request.url)
104+
return response
105+
106+
httpserver.expect_request('/').with_post_hook(request_handler).respond_with_json(
107+
{
108+
'connected': True,
109+
'connectionError': None,
110+
'isManInTheMiddle': True,
111+
},
112+
status=200,
87113
)
88114

89115
await Actor.init()
@@ -138,6 +164,6 @@ async def test_proxy_configuration_with_actor_proxy_input(
138164
)
139165

140166
assert len(patched_apify_client.calls['user']['get']) == 2 # type: ignore[attr-defined]
141-
assert len(route.calls) == 2
167+
assert call_mock.call_count == 2
142168

143169
await Actor.exit()

tests/unit/actor/test_request_list.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
import re
44
from dataclasses import dataclass
5-
from typing import Any, get_args
5+
from typing import TYPE_CHECKING, Any, get_args
6+
from unittest.mock import Mock
67

78
import pytest
8-
import respx
9-
from httpx import Response
9+
from yarl import URL
1010

1111
from crawlee._request import UserData
1212
from crawlee._types import HttpMethod
1313

1414
from apify.storages._request_list import URL_NO_COMMAS_REGEX, RequestList
1515

16+
if TYPE_CHECKING:
17+
from pytest_httpserver import HTTPServer
18+
from werkzeug import Request, Response
19+
1620

1721
@pytest.mark.parametrize(
1822
argnames='request_method',
@@ -67,37 +71,48 @@ async def test_request_list_open_request_types(
6771
assert request.headers.root == optional_input.get('headers', {})
6872

6973

70-
@respx.mock
71-
async def test_request_list_open_from_url_correctly_send_requests() -> None:
74+
async def test_request_list_open_from_url_correctly_send_requests(httpserver: HTTPServer) -> None:
7275
"""Test that requests are sent to expected urls."""
7376
request_list_sources_input: list[dict[str, Any]] = [
7477
{
75-
'requestsFromUrl': 'https://abc.dev/file.txt',
78+
'requestsFromUrl': httpserver.url_for('/file.txt'),
7679
'method': 'GET',
7780
},
7881
{
79-
'requestsFromUrl': 'https://www.abc.dev/file2',
82+
'requestsFromUrl': httpserver.url_for('/file2'),
8083
'method': 'PUT',
8184
},
8285
{
83-
'requestsFromUrl': 'https://www.something.som',
86+
'requestsFromUrl': httpserver.url_for('/something'),
8487
'method': 'POST',
8588
'headers': {'key': 'value'},
8689
'payload': 'some_payload',
8790
'userData': {'another_key': 'another_value'},
8891
},
8992
]
9093

91-
routes = [respx.get(entry['requestsFromUrl']) for entry in request_list_sources_input]
94+
routes: dict[str, Mock] = {}
95+
96+
def request_handler(request: Request, response: Response) -> Response:
97+
routes[request.url]()
98+
return response
99+
100+
for entry in request_list_sources_input:
101+
path = str(URL(entry['requestsFromUrl']).path)
102+
httpserver.expect_oneshot_request(path).with_post_hook(request_handler).respond_with_data(status=200)
103+
routes[entry['requestsFromUrl']] = Mock()
92104

93105
await RequestList.open(request_list_sources_input=request_list_sources_input)
94106

95-
for route in routes:
96-
assert route.called
107+
assert len(routes) == len(request_list_sources_input)
97108

109+
for entity in request_list_sources_input:
110+
entity_url = entity['requestsFromUrl']
111+
assert entity_url in routes
112+
assert routes[entity_url].called
98113

99-
@respx.mock
100-
async def test_request_list_open_from_url() -> None:
114+
115+
async def test_request_list_open_from_url(httpserver: HTTPServer) -> None:
101116
"""Test that create_request_list is correctly reading urls from remote url sources and also from simple input."""
102117
expected_simple_url = 'https://www.someurl.com'
103118
expected_remote_urls_1 = {'http://www.something.com', 'https://www.somethingelse.com', 'http://www.bla.net'}
@@ -111,11 +126,11 @@ class MockedUrlInfo:
111126

112127
mocked_urls = (
113128
MockedUrlInfo(
114-
'https://abc.dev/file.txt',
129+
httpserver.url_for('/file.txt'),
115130
'blablabla{} more blablabla{} , even more blablabla. {} '.format(*expected_remote_urls_1),
116131
),
117132
MockedUrlInfo(
118-
'https://www.abc.dev/file2',
133+
httpserver.url_for('/file2'),
119134
'some stuff{} more stuff{} www.false_positive.com'.format(*expected_remote_urls_2),
120135
),
121136
)
@@ -132,7 +147,8 @@ class MockedUrlInfo:
132147
},
133148
]
134149
for mocked_url in mocked_urls:
135-
respx.get(mocked_url.url).mock(return_value=Response(200, text=mocked_url.response_text))
150+
path = str(URL(mocked_url.url).path)
151+
httpserver.expect_oneshot_request(path).respond_with_data(status=200, response_data=mocked_url.response_text)
136152

137153
request_list = await RequestList.open(request_list_sources_input=request_list_sources_input)
138154
generated_requests = []
@@ -143,23 +159,20 @@ class MockedUrlInfo:
143159
assert {generated_request.url for generated_request in generated_requests} == expected_urls
144160

145161

146-
@respx.mock
147-
async def test_request_list_open_from_url_additional_inputs() -> None:
162+
async def test_request_list_open_from_url_additional_inputs(httpserver: HTTPServer) -> None:
148163
"""Test that all generated request properties are correctly populated from input values."""
149164
expected_url = 'https://www.someurl.com'
150165
example_start_url_input: dict[str, Any] = {
151-
'requestsFromUrl': 'https://crawlee.dev/file.txt',
166+
'requestsFromUrl': httpserver.url_for('/file.txt'),
152167
'method': 'POST',
153168
'headers': {'key': 'value'},
154169
'payload': 'some_payload',
155170
'userData': {'another_key': 'another_value'},
156171
}
157-
158-
respx.get(example_start_url_input['requestsFromUrl']).mock(return_value=Response(200, text=expected_url))
172+
httpserver.expect_oneshot_request('/file.txt').respond_with_data(status=200, response_data=expected_url)
159173

160174
request_list = await RequestList.open(request_list_sources_input=[example_start_url_input])
161175
request = await request_list.fetch_next_request()
162-
163176
# Check all properties correctly created for request
164177
assert request
165178
assert request.url == expected_url

tests/unit/conftest.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
import inspect
55
import os
66
from collections import defaultdict
7+
from logging import getLogger
78
from typing import TYPE_CHECKING, Any, get_type_hints
89

910
import pytest
11+
from pytest_httpserver import HTTPServer
1012

1113
from apify_client import ApifyClientAsync
1214
from apify_shared.consts import ApifyEnvVars
@@ -18,7 +20,7 @@
1820
import apify._actor
1921

2022
if TYPE_CHECKING:
21-
from collections.abc import Callable
23+
from collections.abc import Callable, Iterator
2224
from pathlib import Path
2325

2426

@@ -187,3 +189,23 @@ def memory_storage_client() -> MemoryStorageClient:
187189
configuration.write_metadata = True
188190

189191
return MemoryStorageClient.from_config(configuration)
192+
193+
194+
@pytest.fixture(scope='session')
195+
def make_httpserver() -> Iterator[HTTPServer]:
196+
werkzeug_logger = getLogger('werkzeug')
197+
werkzeug_logger.disabled = True
198+
199+
server = HTTPServer(threaded=True, host='127.0.0.1')
200+
server.start()
201+
yield server
202+
server.clear() # type: ignore[no-untyped-call]
203+
if server.is_running():
204+
server.stop() # type: ignore[no-untyped-call]
205+
206+
207+
@pytest.fixture
208+
def httpserver(make_httpserver: HTTPServer) -> Iterator[HTTPServer]:
209+
server = make_httpserver
210+
yield server
211+
server.clear() # type: ignore[no-untyped-call]

0 commit comments

Comments
 (0)