Skip to content

Commit 662fbd5

Browse files
committed
Merge branch 'master' into new-apify-storage-clients
2 parents ae3044e + 7e4bb38 commit 662fbd5

11 files changed

+573
-141
lines changed

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,16 @@ dev = [
7272
"pydoc-markdown~=4.8.0",
7373
"pytest-asyncio~=1.1.0",
7474
"pytest-cov~=6.2.0",
75+
"pytest-httpserver>=1.1.3",
7576
"pytest-timeout>=2.4.0",
7677
"pytest-xdist~=3.8.0",
7778
"pytest~=8.4.0",
78-
"respx~=0.22.0",
7979
"ruff~=0.12.0",
8080
"setuptools", # setuptools are used by pytest but not explicitly required
8181
"types-cachetools>=6.0.0.20250525",
8282
"uvicorn[standard]",
83+
"werkzeug~=3.0.0", # Werkzeug is used by httpserver
84+
"yarl~=1.20.0", # yarl is used by crawlee
8385
]
8486

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

tests/unit/actor/test_actor_create_proxy_configuration.py

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

33
from typing import TYPE_CHECKING
4+
from unittest.mock import Mock
45

5-
import httpx
66
import pytest
77

88
from apify_client import ApifyClientAsync
@@ -11,7 +11,8 @@
1111
from apify import Actor
1212

1313
if TYPE_CHECKING:
14-
from respx import MockRouter
14+
from pytest_httpserver import HTTPServer
15+
from werkzeug import Request, Response
1516

1617
from ..conftest import ApifyClientAsyncPatcher
1718

@@ -24,25 +25,29 @@ def patched_apify_client(apify_client_async_patcher: ApifyClientAsyncPatcher) ->
2425
return ApifyClientAsync()
2526

2627

28+
@pytest.mark.usefixtures('patched_httpx_client')
2729
async def test_basic_proxy_configuration_creation(
2830
monkeypatch: pytest.MonkeyPatch,
29-
respx_mock: MockRouter,
31+
httpserver: HTTPServer,
3032
patched_apify_client: ApifyClientAsync,
3133
) -> None:
32-
dummy_proxy_status_url = 'http://dummy-proxy-status-url.com'
34+
dummy_proxy_status_url = str(httpserver.url_for('/')).removesuffix('/')
3335
monkeypatch.setenv(ApifyEnvVars.TOKEN.value, 'DUMMY_TOKEN')
3436
monkeypatch.setenv(ApifyEnvVars.PROXY_STATUS_URL.value, dummy_proxy_status_url)
3537

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-
)
38+
call_mock = Mock()
39+
40+
def request_handler(request: Request, response: Response) -> Response:
41+
call_mock(request.url)
42+
return response
43+
44+
httpserver.expect_oneshot_request('/').with_post_hook(request_handler).respond_with_json(
45+
{
46+
'connected': True,
47+
'connectionError': None,
48+
'isManInTheMiddle': True,
49+
},
50+
status=200,
4651
)
4752

4853
groups = ['GROUP1', 'GROUP2']
@@ -58,32 +63,36 @@ async def test_basic_proxy_configuration_creation(
5863
assert proxy_configuration._country_code == country_code
5964

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

6368
await Actor.exit()
6469

6570

71+
@pytest.mark.usefixtures('patched_httpx_client')
6672
async def test_proxy_configuration_with_actor_proxy_input(
6773
monkeypatch: pytest.MonkeyPatch,
68-
respx_mock: MockRouter,
74+
httpserver: HTTPServer,
6975
patched_apify_client: ApifyClientAsync,
7076
) -> None:
71-
dummy_proxy_status_url = 'http://dummy-proxy-status-url.com'
77+
dummy_proxy_status_url = str(httpserver.url_for('/')).removesuffix('/')
7278
dummy_proxy_url = 'http://dummy-proxy.com:8000'
7379

7480
monkeypatch.setenv(ApifyEnvVars.TOKEN.value, 'DUMMY_TOKEN')
7581
monkeypatch.setenv(ApifyEnvVars.PROXY_STATUS_URL.value, dummy_proxy_status_url)
7682

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-
)
83+
call_mock = Mock()
84+
85+
def request_handler(request: Request, response: Response) -> Response:
86+
call_mock(request.url)
87+
return response
88+
89+
httpserver.expect_request('/').with_post_hook(request_handler).respond_with_json(
90+
{
91+
'connected': True,
92+
'connectionError': None,
93+
'isManInTheMiddle': True,
94+
},
95+
status=200,
8796
)
8897

8998
await Actor.init()
@@ -138,6 +147,6 @@ async def test_proxy_configuration_with_actor_proxy_input(
138147
)
139148

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

143152
await Actor.exit()

tests/unit/actor/test_actor_env_helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def test_get_env_with_randomized_env_vars(monkeypatch: pytest.MonkeyPatch)
4343
ApifyEnvVars.SDK_LATEST_VERSION,
4444
ApifyEnvVars.LOG_FORMAT,
4545
ApifyEnvVars.LOG_LEVEL,
46+
ApifyEnvVars.USER_IS_PAYING,
4647
ActorEnvVars.STANDBY_PORT,
4748
ApifyEnvVars.PERSIST_STORAGE,
4849
}

tests/unit/actor/test_request_list.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
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.request_loaders import ApifyRequestList
1515
from apify.request_loaders._apify_request_list import URL_NO_COMMAS_REGEX
1616

17+
if TYPE_CHECKING:
18+
from pytest_httpserver import HTTPServer
19+
from werkzeug import Request, Response
20+
1721

1822
@pytest.mark.parametrize(
1923
argnames='request_method',
@@ -68,37 +72,48 @@ async def test_request_list_open_request_types(
6872
assert request.headers.root == optional_input.get('headers', {})
6973

7074

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

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

94106
await ApifyRequestList.open(request_list_sources_input=request_list_sources_input)
95107

96-
for route in routes:
97-
assert route.called
108+
assert len(routes) == len(request_list_sources_input)
98109

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

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

113128
mocked_urls = (
114129
MockedUrlInfo(
115-
'https://abc.dev/file.txt',
130+
httpserver.url_for('/file.txt'),
116131
'blablabla{} more blablabla{} , even more blablabla. {} '.format(*expected_remote_urls_1),
117132
),
118133
MockedUrlInfo(
119-
'https://www.abc.dev/file2',
134+
httpserver.url_for('/file2'),
120135
'some stuff{} more stuff{} www.false_positive.com'.format(*expected_remote_urls_2),
121136
),
122137
)
@@ -133,7 +148,8 @@ class MockedUrlInfo:
133148
},
134149
]
135150
for mocked_url in mocked_urls:
136-
respx.get(mocked_url.url).mock(return_value=Response(200, text=mocked_url.response_text))
151+
path = str(URL(mocked_url.url).path)
152+
httpserver.expect_oneshot_request(path).respond_with_data(status=200, response_data=mocked_url.response_text)
137153

138154
request_list = await ApifyRequestList.open(request_list_sources_input=request_list_sources_input)
139155
generated_requests = []
@@ -144,23 +160,20 @@ class MockedUrlInfo:
144160
assert {generated_request.url for generated_request in generated_requests} == expected_urls
145161

146162

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

161175
request_list = await ApifyRequestList.open(request_list_sources_input=[example_start_url_input])
162176
request = await request_list.fetch_next_request()
163-
164177
# Check all properties correctly created for request
165178
assert request
166179
assert request.url == expected_url

tests/unit/conftest.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
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

10+
import httpx
911
import pytest
12+
from pytest_httpserver import HTTPServer
1013

1114
from apify_client import ApifyClientAsync
1215
from apify_shared.consts import ApifyEnvVars
@@ -15,7 +18,7 @@
1518
import apify._actor
1619

1720
if TYPE_CHECKING:
18-
from collections.abc import Callable
21+
from collections.abc import Callable, Iterator
1922
from pathlib import Path
2023

2124

@@ -167,3 +170,37 @@ def getattr_override(apify_client_instance: Any, attr_name: str) -> Any:
167170
@pytest.fixture
168171
def apify_client_async_patcher(monkeypatch: pytest.MonkeyPatch) -> ApifyClientAsyncPatcher:
169172
return ApifyClientAsyncPatcher(monkeypatch)
173+
174+
175+
@pytest.fixture(scope='session')
176+
def make_httpserver() -> Iterator[HTTPServer]:
177+
werkzeug_logger = getLogger('werkzeug')
178+
werkzeug_logger.disabled = True
179+
180+
server = HTTPServer(threaded=True, host='127.0.0.1')
181+
server.start()
182+
yield server
183+
server.clear() # type: ignore[no-untyped-call]
184+
if server.is_running():
185+
server.stop() # type: ignore[no-untyped-call]
186+
187+
188+
@pytest.fixture
189+
def httpserver(make_httpserver: HTTPServer) -> Iterator[HTTPServer]:
190+
server = make_httpserver
191+
yield server
192+
server.clear() # type: ignore[no-untyped-call]
193+
194+
195+
@pytest.fixture
196+
def patched_httpx_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
197+
"""Patch httpx client to drop proxy settings."""
198+
199+
class ProxylessAsyncClient(httpx.AsyncClient):
200+
def __init__(self, *args: Any, **kwargs: Any) -> None:
201+
kwargs.pop('proxy', None)
202+
super().__init__(*args, **kwargs)
203+
204+
monkeypatch.setattr(httpx, 'AsyncClient', ProxylessAsyncClient)
205+
yield
206+
monkeypatch.undo()

0 commit comments

Comments
 (0)