Skip to content

Commit 4203bf3

Browse files
committed
resolve
2 parents 8872272 + 61f84e1 commit 4203bf3

16 files changed

+848
-246
lines changed

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
<!-- git-cliff-unreleased-start -->
6+
## 1.12.3 - **not yet released**
7+
8+
### 🚀 Features
9+
10+
- Extend status parameter to an array of possible statuses ([#455](https://github.com/apify/apify-client-python/pull/455)) ([76f6769](https://github.com/apify/apify-client-python/commit/76f676973d067ce8af398d8e6ceea55595da5ecf)) by [@JanHranicky](https://github.com/JanHranicky)
11+
12+
13+
<!-- git-cliff-unreleased-end -->
14+
## [1.12.2](https://github.com/apify/apify-client-python/releases/tag/v1.12.2) (2025-08-08)
15+
16+
### 🐛 Bug Fixes
17+
18+
- Fix API error with stream ([#459](https://github.com/apify/apify-client-python/pull/459)) ([0c91ca5](https://github.com/apify/apify-client-python/commit/0c91ca516a01a6fca7bc8fa07f7bf9c15c75bf9d)) by [@Pijukatel](https://github.com/Pijukatel)
19+
20+
521
## [1.12.1](https://github.com/apify/apify-client-python/releases/tag/v1.12.1) (2025-07-30)
622

723
### 🐛 Bug Fixes
@@ -388,4 +404,4 @@ All notable changes to this project will be documented in this file.
388404

389405
## [0.0.1](https://github.com/apify/apify-client-python/releases/tag/v0.0.1) (2021-05-13)
390406

391-
- Initial release of the package.
407+
- Initial release of the package.

pyproject.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apify_client"
7-
version = "1.12.1"
7+
version = "1.12.3"
88
description = "Apify API client for Python"
99
authors = [{ name = "Apify Technologies s.r.o.", email = "[email protected]" }]
1010
license = { file = "LICENSE" }
@@ -42,20 +42,22 @@ dependencies = [
4242
[dependency-groups]
4343
dev = [
4444
"dycw-pytest-only>=2.1.1",
45-
"griffe~=1.9.0",
45+
"griffe~=1.11.0",
4646
"mypy~=1.17.0",
47-
"pre-commit~=4.2.0",
47+
"pre-commit~=4.3.0",
4848
"pydoc-markdown~=4.8.0",
4949
"pytest-asyncio~=1.1.0",
5050
"pytest-cov~=6.2.0",
5151
"pytest-timeout>=2.4.0",
5252
"pytest-xdist~=3.8.0",
5353
"pytest~=8.4.0",
54+
"pytest-httpserver>=1.1.3",
5455
"redbaron~=0.9.0",
5556
"respx~=0.22.0",
5657
"ruff~=0.12.0",
5758
"setuptools", # setuptools are used by pytest but not explicitly required
5859
"types-colorama~=0.4.15.20240106",
60+
"werkzeug~=3.0.0", # Werkzeug is used by pytest-httpserver
5961
]
6062

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

src/apify_client/_http_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response:
214214
if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004
215215
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
216216
stop_retrying()
217+
218+
# Read the response in case it is a stream, so we can raise the error properly
219+
response.read()
217220
raise ApifyApiError(response, attempt)
218221

219222
return retry_with_exp_backoff(
@@ -304,6 +307,9 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response
304307
if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004
305308
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
306309
stop_retrying()
310+
311+
# Read the response in case it is a stream, so we can raise the error properly
312+
await response.aread()
307313
raise ApifyApiError(response, attempt)
308314

309315
return await retry_with_exp_backoff_async(

src/apify_client/clients/resource_clients/run_collection.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def list(
2626
limit: int | None = None,
2727
offset: int | None = None,
2828
desc: bool | None = None,
29-
status: ActorJobStatus | None = None,
29+
status: ActorJobStatus | list[ActorJobStatus] | None = None,
3030
) -> ListPage[dict]:
3131
"""List all Actor runs.
3232
@@ -40,16 +40,21 @@ def list(
4040
limit: How many runs to retrieve.
4141
offset: What run to include as first when retrieving the list.
4242
desc: Whether to sort the runs in descending order based on their start date.
43-
status: Retrieve only runs with the provided status.
43+
status: Retrieve only runs with the provided statuses.
4444
4545
Returns:
4646
The retrieved Actor runs.
4747
"""
48+
if isinstance(status, list):
49+
status_param = [maybe_extract_enum_member_value(s) for s in status]
50+
else:
51+
status_param = maybe_extract_enum_member_value(status)
52+
4853
return self._list(
4954
limit=limit,
5055
offset=offset,
5156
desc=desc,
52-
status=maybe_extract_enum_member_value(status),
57+
status=status_param,
5358
)
5459

5560

@@ -67,7 +72,7 @@ async def list(
6772
limit: int | None = None,
6873
offset: int | None = None,
6974
desc: bool | None = None,
70-
status: ActorJobStatus | None = None,
75+
status: ActorJobStatus | list[ActorJobStatus] | None = None,
7176
) -> ListPage[dict]:
7277
"""List all Actor runs.
7378
@@ -81,14 +86,19 @@ async def list(
8186
limit: How many runs to retrieve.
8287
offset: What run to include as first when retrieving the list.
8388
desc: Whether to sort the runs in descending order based on their start date.
84-
status: Retrieve only runs with the provided status.
89+
status: Retrieve only runs with the provided statuses.
8590
8691
Returns:
8792
The retrieved Actor runs.
8893
"""
94+
if isinstance(status, list):
95+
status_param = [maybe_extract_enum_member_value(s) for s in status]
96+
else:
97+
status_param = maybe_extract_enum_member_value(status)
98+
8999
return await self._list(
90100
limit=limit,
91101
offset=offset,
92102
desc=desc,
93-
status=maybe_extract_enum_member_value(status),
103+
status=status_param,
94104
)

tests/integration/__init__.py

Whitespace-only changes.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
7+
if TYPE_CHECKING:
8+
from apify_client import ApifyClient
9+
10+
from apify_shared.consts import ActorJobStatus
11+
12+
pytestmark = pytest.mark.integration
13+
14+
15+
class TestRunCollectionSync:
16+
APIFY_HELLO_WORLD_ACTOR = 'apify/hello-world'
17+
created_runs: list[dict]
18+
19+
def setup_runs(self, apify_client: ApifyClient) -> None:
20+
self.created_runs = []
21+
22+
successfull_run = apify_client.actor(self.APIFY_HELLO_WORLD_ACTOR).call()
23+
if successfull_run is not None:
24+
self.created_runs.append(successfull_run)
25+
26+
timed_out_run = apify_client.actor(self.APIFY_HELLO_WORLD_ACTOR).call(timeout_secs=1)
27+
if timed_out_run is not None:
28+
self.created_runs.append(timed_out_run)
29+
30+
def teadown_runs(self, apify_client: ApifyClient) -> None:
31+
for run in self.created_runs:
32+
run_id = run.get('id')
33+
if isinstance(run_id, str):
34+
apify_client.run(run_id).delete()
35+
36+
async def test_run_collection_list_multiple_statuses(self, apify_client: ApifyClient) -> None:
37+
self.setup_runs(apify_client)
38+
39+
run_collection = apify_client.actor(self.APIFY_HELLO_WORLD_ACTOR).runs()
40+
41+
multiple_status_runs = run_collection.list(status=[ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT])
42+
single_status_runs = run_collection.list(status=ActorJobStatus.SUCCEEDED)
43+
44+
assert multiple_status_runs is not None
45+
assert single_status_runs is not None
46+
47+
assert hasattr(multiple_status_runs, 'items')
48+
assert hasattr(single_status_runs, 'items')
49+
50+
assert all(
51+
run.get('status') in [ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT]
52+
for run in multiple_status_runs.items
53+
)
54+
assert all(run.get('status') == ActorJobStatus.SUCCEEDED for run in single_status_runs.items)
55+
56+
self.teadown_runs(apify_client)

tests/unit/__init__.py

Whitespace-only changes.

tests/unit/conftest.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from collections.abc import Iterable, Iterator
2+
from logging import getLogger
3+
4+
import pytest
5+
from pytest_httpserver import HTTPServer
6+
7+
8+
@pytest.fixture(scope='session')
9+
def make_httpserver() -> Iterable[HTTPServer]:
10+
werkzeug_logger = getLogger('werkzeug')
11+
werkzeug_logger.disabled = True
12+
13+
server = HTTPServer(threaded=True, host='127.0.0.1')
14+
server.start()
15+
yield server
16+
server.clear() # type: ignore[no-untyped-call]
17+
if server.is_running():
18+
server.stop() # type: ignore[no-untyped-call]
19+
20+
21+
@pytest.fixture
22+
def httpserver(make_httpserver: HTTPServer) -> Iterable[HTTPServer]:
23+
server = make_httpserver
24+
yield server
25+
server.clear() # type: ignore[no-untyped-call]
26+
27+
28+
@pytest.fixture
29+
def patch_basic_url(httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
30+
server_url = httpserver.url_for('/').removesuffix('/')
31+
monkeypatch.setattr('apify_client.client.DEFAULT_API_URL', server_url)
32+
yield
33+
monkeypatch.undo()

tests/unit/test_client_errors.py

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,114 @@
1+
from __future__ import annotations
2+
13
import json
2-
from collections.abc import Generator
4+
import time
5+
from typing import TYPE_CHECKING
36

4-
import httpx
57
import pytest
6-
import respx
8+
from werkzeug import Response
79

810
from apify_client._errors import ApifyApiError
911
from apify_client._http_client import HTTPClient, HTTPClientAsync
1012

11-
_TEST_URL = 'http://example.com'
13+
if TYPE_CHECKING:
14+
from collections.abc import Iterator
15+
16+
from pytest_httpserver import HTTPServer
17+
from werkzeug import Request
18+
19+
_TEST_PATH = '/errors'
1220
_EXPECTED_MESSAGE = 'some_message'
1321
_EXPECTED_TYPE = 'some_type'
1422
_EXPECTED_DATA = {
1523
'invalidItems': {'0': ["should have required property 'name'"], '1': ["should have required property 'name'"]}
1624
}
1725

26+
RAW_ERROR = (
27+
b'{\n'
28+
b' "error": {\n'
29+
b' "type": "insufficient-permissions",\n'
30+
b' "message": "Insufficient permissions for the Actor run. Make sure you\''
31+
b're passing a correct API token and that it has the required permissions."\n'
32+
b' }\n'
33+
b'}'
34+
)
35+
36+
37+
@pytest.fixture
38+
def test_endpoint(httpserver: HTTPServer) -> str:
39+
httpserver.expect_request(_TEST_PATH).respond_with_json(
40+
{'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}, status=400
41+
)
42+
return str(httpserver.url_for(_TEST_PATH))
43+
1844

19-
@pytest.fixture(autouse=True)
20-
def mocked_response() -> Generator[respx.MockRouter]:
21-
response_content = json.dumps(
22-
{'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}
45+
def streaming_handler(_request: Request) -> Response:
46+
"""Handler for streaming log requests."""
47+
48+
def generate_response() -> Iterator[bytes]:
49+
for i in range(len(RAW_ERROR)):
50+
yield RAW_ERROR[i : i + 1]
51+
time.sleep(0.01)
52+
53+
return Response(
54+
response=(RAW_ERROR[i : i + 1] for i in range(len(RAW_ERROR))),
55+
status=403,
56+
mimetype='application/octet-stream',
57+
headers={'Content-Length': str(len(RAW_ERROR))},
2358
)
24-
with respx.mock() as respx_mock:
25-
respx_mock.get(_TEST_URL).mock(return_value=httpx.Response(400, content=response_content))
26-
yield respx_mock
2759

2860

29-
def test_client_apify_api_error_with_data() -> None:
61+
def test_client_apify_api_error_with_data(test_endpoint: str) -> None:
3062
"""Test that client correctly throws ApifyApiError with error data from response."""
3163
client = HTTPClient()
3264

3365
with pytest.raises(ApifyApiError) as e:
34-
client.call(method='GET', url=_TEST_URL)
66+
client.call(method='GET', url=test_endpoint)
3567

3668
assert e.value.message == _EXPECTED_MESSAGE
3769
assert e.value.type == _EXPECTED_TYPE
3870
assert e.value.data == _EXPECTED_DATA
3971

4072

41-
async def test_async_client_apify_api_error_with_data() -> None:
73+
async def test_async_client_apify_api_error_with_data(test_endpoint: str) -> None:
4274
"""Test that async client correctly throws ApifyApiError with error data from response."""
4375
client = HTTPClientAsync()
4476

4577
with pytest.raises(ApifyApiError) as e:
46-
await client.call(method='GET', url=_TEST_URL)
78+
await client.call(method='GET', url=test_endpoint)
4779

4880
assert e.value.message == _EXPECTED_MESSAGE
4981
assert e.value.type == _EXPECTED_TYPE
5082
assert e.value.data == _EXPECTED_DATA
83+
84+
85+
def test_client_apify_api_error_streamed(httpserver: HTTPServer) -> None:
86+
"""Test that client correctly throws ApifyApiError when the response has stream."""
87+
88+
error = json.loads(RAW_ERROR.decode())
89+
90+
client = HTTPClient()
91+
92+
httpserver.expect_request('/stream_error').respond_with_handler(streaming_handler)
93+
94+
with pytest.raises(ApifyApiError) as e:
95+
client.call(method='GET', url=httpserver.url_for('/stream_error'), stream=True, parse_response=False)
96+
97+
assert e.value.message == error['error']['message']
98+
assert e.value.type == error['error']['type']
99+
100+
101+
async def test_async_client_apify_api_error_streamed(httpserver: HTTPServer) -> None:
102+
"""Test that async client correctly throws ApifyApiError when the response has stream."""
103+
104+
error = json.loads(RAW_ERROR.decode())
105+
106+
client = HTTPClientAsync()
107+
108+
httpserver.expect_request('/stream_error').respond_with_handler(streaming_handler)
109+
110+
with pytest.raises(ApifyApiError) as e:
111+
await client.call(method='GET', url=httpserver.url_for('/stream_error'), stream=True, parse_response=False)
112+
113+
assert e.value.message == error['error']['message']
114+
assert e.value.type == error['error']['type']

0 commit comments

Comments
 (0)