Skip to content

Commit 2cfd8a5

Browse files
authored
fix: accept arbitrary JSON userData in ApifyRequestList (#966)
### Description - `_RequestDetails.user_data` was typed `dict[str, str]`, but the platform `requestListSources` input allows arbitrary JSON values in `userData`, so e.g. `{"depth": 1}` raised a `ValidationError`. Retyped to `dict[str, JsonSerializable]` (the same alias crawlee's `Request.from_url` declares). - Fixed a related pre-existing crash: crawlee's `Request.from_url` mutates a non-empty `user_data` dict passed to it (writes `__crawlee` back into it), and `_process_remote_url` reused one dict for every URL extracted from a `requestsFromUrl` source. Any such source with non-empty `userData` and 2+ extracted URLs crashed with a `TypeError` on the second request. Each call now gets its own copy of the dict. - Added regression tests for both cases.
1 parent f96817d commit 2cfd8a5

2 files changed

Lines changed: 40 additions & 4 deletions

File tree

src/apify/request_loaders/_apify_request_list.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
import asyncio
44
import re
5+
from copy import deepcopy
56
from itertools import chain
67
from typing import Annotated, Any
78

89
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
910
from pydantic.alias_generators import to_camel
1011

11-
from crawlee._types import HttpMethod
12+
from crawlee._types import HttpMethod, JsonSerializable
1213
from crawlee.http_clients import HttpClient, ImpitHttpClient
1314
from crawlee.request_loaders import RequestList
1415

@@ -26,7 +27,7 @@ class _RequestDetails(BaseModel):
2627
method: HttpMethod = 'GET'
2728
payload: str = ''
2829
headers: Annotated[dict[str, str], Field(default_factory=dict)]
29-
user_data: Annotated[dict[str, str], Field(default_factory=dict)]
30+
user_data: Annotated[dict[str, JsonSerializable], Field(default_factory=dict)]
3031

3132

3233
class _RequestsFromUrlInput(_RequestDetails):
@@ -154,7 +155,9 @@ async def _process_remote_url(request_input: _RequestsFromUrlInput, http_client:
154155
method=request_input.method,
155156
payload=request_input.payload.encode('utf-8'),
156157
headers=request_input.headers,
157-
user_data=request_input.user_data,
158+
# Deep-copy so `Request.from_url` (which writes `__crawlee` into the dict) cannot corrupt
159+
# the shared input, and nested JSON values are not aliased across the requests.
160+
user_data=deepcopy(request_input.user_data),
158161
)
159162
for match in matches
160163
]

tests/unit/actor/test_request_list.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,19 @@
4141
},
4242
id='all_options',
4343
),
44+
pytest.param(
45+
{
46+
'userData': {'depth': 1, 'isStartUrl': True, 'nested': {'key': 'value'}},
47+
},
48+
id='non_string_user_data',
49+
),
4450
],
4551
)
4652
async def test_request_list_open_request_types(
4753
request_method: HttpMethod,
4854
optional_input: dict[str, Any],
4955
) -> None:
50-
"""Test proper request list generation from both minimal and full inputs for all method types for simple input."""
56+
"""Test proper request list generation from various optional inputs for all method types for simple input."""
5157
minimal_request_dict_input = {
5258
'url': 'https://www.abc.com',
5359
'method': request_method,
@@ -190,6 +196,33 @@ async def test_request_list_open_from_url_additional_inputs(httpserver: HTTPServ
190196
assert request.user_data == expected_user_data
191197

192198

199+
async def test_request_list_open_from_url_with_user_data_and_multiple_urls(httpserver: HTTPServer) -> None:
200+
"""Test that a remote source with `userData` yielding multiple URLs creates all requests with that user data."""
201+
expected_urls = {'https://www.one.com', 'https://www.two.com'}
202+
httpserver.expect_oneshot_request('/file.txt').respond_with_data(status=200, response_data=' '.join(expected_urls))
203+
204+
request_list = await ApifyRequestList.open(
205+
request_list_sources_input=[
206+
{'requestsFromUrl': httpserver.url_for('/file.txt'), 'userData': {'depth': 1, 'nested': {'key': 'value'}}},
207+
],
208+
)
209+
210+
requests = []
211+
while request := await request_list.fetch_next_request():
212+
requests.append(request)
213+
214+
assert {request.url for request in requests} == expected_urls
215+
for request in requests:
216+
assert request.user_data['depth'] == 1
217+
assert request.user_data['nested'] == {'key': 'value'}
218+
219+
# Each request owns an independent copy; mutating one must not leak into the others.
220+
nested = requests[0].user_data['nested']
221+
assert isinstance(nested, dict)
222+
nested['key'] = 'mutated'
223+
assert requests[1].user_data['nested'] == {'key': 'value'}
224+
225+
193226
async def test_request_list_open_from_url_non_utf8_body(httpserver: HTTPServer) -> None:
194227
"""Test that a non-UTF-8 response body does not crash ApifyRequestList.open."""
195228
expected_url = 'https://www.someurl.com'

0 commit comments

Comments
 (0)