Skip to content

Commit bc092b8

Browse files
committed
fix: Read file-like values before upload in the impit transport
1 parent af6d0f7 commit bc092b8

3 files changed

Lines changed: 77 additions & 8 deletions

File tree

src/apify_client/_utils/encoding.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,20 @@ def encode_key_value_store_record_value(value: Any, *, content_type: str | None
2222
Returns:
2323
A tuple of (encoded_value, content_type).
2424
"""
25+
# Read file-like values into memory; the underlying HTTP transport only accepts bytes-like bodies,
26+
# so a file object would otherwise reach it unread and raise a raw `TypeError`.
27+
if isinstance(value, io.IOBase):
28+
value = value.read()
29+
2530
if not content_type:
26-
if isinstance(value, (bytes, bytearray, io.IOBase)):
31+
if isinstance(value, (bytes, bytearray)):
2732
content_type = 'application/octet-stream'
2833
elif isinstance(value, str):
2934
content_type = 'text/plain; charset=utf-8'
3035
else:
3136
content_type = 'application/json; charset=utf-8'
3237

33-
if (
34-
'application/json' in content_type
35-
and not isinstance(value, (bytes, bytearray, io.IOBase))
36-
and not isinstance(value, str)
37-
):
38+
if 'application/json' in content_type and not isinstance(value, (bytes, bytearray, str)):
3839
# Don't use indentation to reduce size.
3940
value = json.dumps(
4041
value,

tests/unit/test_key_value_store.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import gzip
4+
import io
5+
from typing import TYPE_CHECKING
6+
7+
from werkzeug import Request, Response
8+
9+
from apify_client import ApifyClient, ApifyClientAsync
10+
11+
if TYPE_CHECKING:
12+
from pytest_httpserver import HTTPServer
13+
14+
_MOCKED_KVS_ID = 'test_kvs_id'
15+
_RECORD_PATH = f'/v2/key-value-stores/{_MOCKED_KVS_ID}/records/f'
16+
17+
18+
def _decode_body(request: Request) -> bytes:
19+
raw = request.get_data()
20+
return gzip.decompress(raw) if request.headers.get('Content-Encoding') == 'gzip' else raw
21+
22+
23+
def test_set_record_reads_file_like_value_sync(httpserver: HTTPServer) -> None:
24+
"""Regression test: a file-like value is read and its bytes are uploaded, not passed through unread."""
25+
captured_requests: list[Request] = []
26+
27+
def capture_request(request: Request) -> Response:
28+
captured_requests.append(request)
29+
return Response(status=201)
30+
31+
httpserver.expect_request(_RECORD_PATH, method='PUT').respond_with_handler(capture_request)
32+
33+
api_url = httpserver.url_for('/').removesuffix('/')
34+
client = ApifyClient(token='test_token', api_url=api_url)
35+
36+
client.key_value_store(_MOCKED_KVS_ID).set_record('f', io.BytesIO(b'buffer data'))
37+
38+
assert len(captured_requests) == 1
39+
assert _decode_body(captured_requests[0]) == b'buffer data'
40+
assert captured_requests[0].headers['content-type'] == 'application/octet-stream'
41+
42+
43+
async def test_set_record_reads_file_like_value_async(httpserver: HTTPServer) -> None:
44+
"""Regression test: a file-like value is read and its bytes are uploaded, not passed through unread."""
45+
captured_requests: list[Request] = []
46+
47+
def capture_request(request: Request) -> Response:
48+
captured_requests.append(request)
49+
return Response(status=201)
50+
51+
httpserver.expect_request(_RECORD_PATH, method='PUT').respond_with_handler(capture_request)
52+
53+
api_url = httpserver.url_for('/').removesuffix('/')
54+
client = ApifyClientAsync(token='test_token', api_url=api_url)
55+
56+
await client.key_value_store(_MOCKED_KVS_ID).set_record('f', io.BytesIO(b'buffer data'))
57+
58+
assert len(captured_requests) == 1
59+
assert _decode_body(captured_requests[0]) == b'buffer data'
60+
assert captured_requests[0].headers['content-type'] == 'application/octet-stream'

tests/unit/test_utils.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,13 +249,21 @@ def test_encode_key_value_store_record_value(
249249

250250

251251
def test_encode_key_value_store_record_value_bytesio() -> None:
252-
"""Test that BytesIO is encoded as octet-stream."""
252+
"""Test that BytesIO is read into bytes and encoded as octet-stream."""
253253
buffer = io.BytesIO(b'buffer data')
254254
value, content_type = encode_key_value_store_record_value(buffer)
255-
assert value == buffer
255+
assert value == b'buffer data'
256256
assert content_type == 'application/octet-stream'
257257

258258

259+
def test_encode_key_value_store_record_value_stringio() -> None:
260+
"""Test that StringIO is read into text and encoded as text/plain."""
261+
buffer = io.StringIO('buffer data')
262+
value, content_type = encode_key_value_store_record_value(buffer)
263+
assert value == 'buffer data'
264+
assert content_type == 'text/plain; charset=utf-8'
265+
266+
259267
def test_response_to_dict() -> None:
260268
"""Test parsing response as dictionary."""
261269
mock_response = Mock()

0 commit comments

Comments
 (0)