scrapely-client-python 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scrapely-client-python might be problematic. Click here for more details.
- scrapely_client/__init__.py +30 -0
- scrapely_client/_charging.py +186 -0
- scrapely_client/_client_registry.py +136 -0
- scrapely_client/_consts.py +96 -0
- scrapely_client/_docs.py +37 -0
- scrapely_client/_literals.py +539 -0
- scrapely_client/_logging.py +180 -0
- scrapely_client/_models.py +4172 -0
- scrapely_client/_pagination.py +220 -0
- scrapely_client/_resource_clients/__init__.py +84 -0
- scrapely_client/_resource_clients/_resource_client.py +567 -0
- scrapely_client/_resource_clients/actor.py +1042 -0
- scrapely_client/_resource_clients/actor_collection.py +377 -0
- scrapely_client/_resource_clients/actor_env_var.py +154 -0
- scrapely_client/_resource_clients/actor_env_var_collection.py +165 -0
- scrapely_client/_resource_clients/actor_version.py +254 -0
- scrapely_client/_resource_clients/actor_version_collection.py +232 -0
- scrapely_client/_resource_clients/build.py +244 -0
- scrapely_client/_resource_clients/build_collection.py +164 -0
- scrapely_client/_resource_clients/dataset.py +1151 -0
- scrapely_client/_resource_clients/dataset_collection.py +244 -0
- scrapely_client/_resource_clients/key_value_store.py +906 -0
- scrapely_client/_resource_clients/key_value_store_collection.py +243 -0
- scrapely_client/_resource_clients/log.py +225 -0
- scrapely_client/_resource_clients/request_queue.py +1167 -0
- scrapely_client/_resource_clients/request_queue_collection.py +240 -0
- scrapely_client/_resource_clients/run.py +875 -0
- scrapely_client/_resource_clients/run_collection.py +226 -0
- scrapely_client/_resource_clients/schedule.py +243 -0
- scrapely_client/_resource_clients/schedule_collection.py +260 -0
- scrapely_client/_resource_clients/store_collection.py +242 -0
- scrapely_client/_resource_clients/task.py +793 -0
- scrapely_client/_resource_clients/task_collection.py +322 -0
- scrapely_client/_resource_clients/user.py +262 -0
- scrapely_client/_resource_clients/webhook.py +292 -0
- scrapely_client/_resource_clients/webhook_collection.py +281 -0
- scrapely_client/_resource_clients/webhook_dispatch.py +84 -0
- scrapely_client/_resource_clients/webhook_dispatch_collection.py +158 -0
- scrapely_client/_scrapely_client.py +837 -0
- scrapely_client/_statistics.py +29 -0
- scrapely_client/_status_message_watcher.py +197 -0
- scrapely_client/_streamed_log.py +250 -0
- scrapely_client/_typeddicts.py +408 -0
- scrapely_client/_utils/__init__.py +0 -0
- scrapely_client/_utils/crypto.py +87 -0
- scrapely_client/_utils/encoding.py +133 -0
- scrapely_client/_utils/errors.py +33 -0
- scrapely_client/_utils/http.py +137 -0
- scrapely_client/_utils/time.py +32 -0
- scrapely_client/_utils/try_import.py +61 -0
- scrapely_client/errors.py +190 -0
- scrapely_client/http_clients/__init__.py +10 -0
- scrapely_client/http_clients/_base.py +430 -0
- scrapely_client/http_clients/_impit.py +598 -0
- scrapely_client/http_compressors/__init__.py +18 -0
- scrapely_client/http_compressors/_base.py +25 -0
- scrapely_client/http_compressors/_brotli.py +38 -0
- scrapely_client/http_compressors/_gzip.py +38 -0
- scrapely_client/http_compressors/_resolve.py +38 -0
- scrapely_client/py.typed +0 -0
- scrapely_client/types.py +52 -0
- scrapely_client_python-1.0.0.dist-info/METADATA +244 -0
- scrapely_client_python-1.0.0.dist-info/RECORD +65 -0
- scrapely_client_python-1.0.0.dist-info/WHEEL +4 -0
- scrapely_client_python-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Literal, get_args
|
|
8
|
+
from urllib.parse import urlencode, urlparse, urlunparse
|
|
9
|
+
|
|
10
|
+
from scrapely_client._consts import DEFAULT_WAIT_FOR_FINISH, DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
|
|
11
|
+
from scrapely_client._docs import docs_group
|
|
12
|
+
from scrapely_client._logging import WithLogDetailsClient
|
|
13
|
+
from scrapely_client._utils.errors import catch_not_found_for_resource_or_throw, catch_not_found_or_throw
|
|
14
|
+
from scrapely_client._utils.http import response_to_dict, to_safe_id
|
|
15
|
+
from scrapely_client._utils.time import to_seconds
|
|
16
|
+
from scrapely_client.errors import ApifyApiError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from scrapely_client._client_registry import ClientRegistry, ClientRegistryAsync
|
|
20
|
+
from scrapely_client.http_clients import HttpClient, HttpClientAsync
|
|
21
|
+
from scrapely_client.types import Timeout
|
|
22
|
+
|
|
23
|
+
_TerminalActorJobStatus = Literal['SUCCEEDED', 'FAILED', 'TIMED-OUT', 'ABORTED']
|
|
24
|
+
"""Subset of `ActorJobStatus` values that indicate the job has finished and will not change again."""
|
|
25
|
+
|
|
26
|
+
_TERMINAL_STATUSES: frozenset[_TerminalActorJobStatus] = frozenset(get_args(_TerminalActorJobStatus))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResourceClientBase(metaclass=WithLogDetailsClient):
|
|
30
|
+
"""Base class with shared implementation for sync and async resource clients.
|
|
31
|
+
|
|
32
|
+
Provides URL building, parameter handling, and client creation utilities.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str,
|
|
39
|
+
public_base_url: str,
|
|
40
|
+
http_client: Any,
|
|
41
|
+
resource_path: str,
|
|
42
|
+
client_registry: Any,
|
|
43
|
+
resource_id: str | None = None,
|
|
44
|
+
params: dict | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Initialize the resource client.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
base_url: API base URL.
|
|
50
|
+
public_base_url: Public CDN base URL.
|
|
51
|
+
http_client: HTTP client for making requests.
|
|
52
|
+
resource_path: Resource endpoint path (e.g., 'actors', 'datasets').
|
|
53
|
+
client_registry: Bundle of client classes for dependency injection.
|
|
54
|
+
resource_id: Optional resource ID for single-resource clients.
|
|
55
|
+
params: Optional default parameters for all requests.
|
|
56
|
+
"""
|
|
57
|
+
if resource_path.endswith('/'):
|
|
58
|
+
raise ValueError('resource_path must not end with "/"')
|
|
59
|
+
|
|
60
|
+
self._base_url = base_url
|
|
61
|
+
self._public_base_url = public_base_url
|
|
62
|
+
self._http_client = http_client
|
|
63
|
+
self._default_params = params or {}
|
|
64
|
+
self._resource_path = resource_path
|
|
65
|
+
self._resource_id = resource_id
|
|
66
|
+
self._client_registry = client_registry
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def resource_id(self) -> str | None:
|
|
70
|
+
"""Get the resource ID."""
|
|
71
|
+
return self._resource_id
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def _resource_url(self) -> str:
|
|
75
|
+
"""Build the full resource URL from base URL, path, and optional ID."""
|
|
76
|
+
url = f'{self._base_url}/{self._resource_path}'
|
|
77
|
+
if self._resource_id is not None:
|
|
78
|
+
url = f'{url}/{to_safe_id(self._resource_id)}'
|
|
79
|
+
return url
|
|
80
|
+
|
|
81
|
+
@cached_property
|
|
82
|
+
def _base_client_kwargs(self) -> dict[str, Any]:
|
|
83
|
+
"""Base kwargs for creating nested/child clients.
|
|
84
|
+
|
|
85
|
+
Returns dict with base_url, public_base_url, http_client, and client_registry. Caller adds
|
|
86
|
+
resource_path, resource_id, and params as needed.
|
|
87
|
+
"""
|
|
88
|
+
return {
|
|
89
|
+
'base_url': self._resource_url,
|
|
90
|
+
'public_base_url': self._public_base_url,
|
|
91
|
+
'http_client': self._http_client,
|
|
92
|
+
'client_registry': self._client_registry,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def _build_url(
|
|
96
|
+
self,
|
|
97
|
+
path: str | None = None,
|
|
98
|
+
*,
|
|
99
|
+
public: bool = False,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Build complete URL for API request.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: Optional path segment to append (e.g., 'runs', 'items').
|
|
105
|
+
public: Whether to use public CDN URL instead of API URL.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Complete URL with optional path and query string.
|
|
109
|
+
"""
|
|
110
|
+
url = f'{self._resource_url}/{path}' if path is not None else self._resource_url
|
|
111
|
+
|
|
112
|
+
if public:
|
|
113
|
+
if not url.startswith(self._base_url):
|
|
114
|
+
raise ValueError(f'URL {url} does not start with base URL {self._base_url}')
|
|
115
|
+
url = url.replace(self._base_url, self._public_base_url, 1)
|
|
116
|
+
|
|
117
|
+
return url
|
|
118
|
+
|
|
119
|
+
def _build_public_url(self, path: str, params: dict[str, Any]) -> str:
|
|
120
|
+
"""Build a public resource URL with API-normalized query params.
|
|
121
|
+
|
|
122
|
+
Normalizes `params` the same way the HTTP request path does (bool -> true/false, list -> comma-joined,
|
|
123
|
+
datetime -> ISO 8601 Zulu) so the shareable URL matches what the client would send over the wire.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path: Path segment appended to the resource URL (e.g. 'items', 'keys').
|
|
127
|
+
params: Query parameters to normalize and append.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The public URL with a normalized query string.
|
|
131
|
+
"""
|
|
132
|
+
public_url = urlparse(self._build_url(path, public=True))
|
|
133
|
+
filtered_params = self._http_client._parse_params(params) # noqa: SLF001
|
|
134
|
+
if filtered_params:
|
|
135
|
+
public_url = public_url._replace(query=urlencode(filtered_params))
|
|
136
|
+
return urlunparse(public_url)
|
|
137
|
+
|
|
138
|
+
def _build_params(self, **kwargs: Any) -> dict:
|
|
139
|
+
"""Merge default params with method params, filtering out None values.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
**kwargs: Method-specific parameters to merge.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Merged parameters with None values removed.
|
|
146
|
+
"""
|
|
147
|
+
merged = {**self._default_params, **kwargs}
|
|
148
|
+
return {k: v for k, v in merged.items() if v is not None}
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _clean_json_payload(data: dict) -> dict:
|
|
152
|
+
"""Remove None values and empty nested dicts from an API request payload.
|
|
153
|
+
|
|
154
|
+
The Scrapely API ignores missing fields but may reject fields explicitly set to None.
|
|
155
|
+
Nested sub-models serialized by Pydantic may produce empty dicts when all their
|
|
156
|
+
fields are None - these are also removed.
|
|
157
|
+
|
|
158
|
+
Uses an iterative stack-based approach, analogous to _build_params for query params.
|
|
159
|
+
"""
|
|
160
|
+
result: dict = {}
|
|
161
|
+
stack: list[tuple[dict, dict]] = [(data, result)]
|
|
162
|
+
|
|
163
|
+
while stack:
|
|
164
|
+
source, target = stack.pop()
|
|
165
|
+
for key, val in source.items():
|
|
166
|
+
if val is None:
|
|
167
|
+
continue
|
|
168
|
+
if isinstance(val, dict):
|
|
169
|
+
nested: dict = {}
|
|
170
|
+
target[key] = nested
|
|
171
|
+
stack.append((val, nested))
|
|
172
|
+
else:
|
|
173
|
+
target[key] = val
|
|
174
|
+
|
|
175
|
+
# Remove dicts that became empty after None filtering
|
|
176
|
+
def _remove_empty(d: dict) -> None:
|
|
177
|
+
for key in [k for k, v in d.items() if isinstance(v, dict)]:
|
|
178
|
+
_remove_empty(d[key])
|
|
179
|
+
if not d[key]:
|
|
180
|
+
del d[key]
|
|
181
|
+
|
|
182
|
+
_remove_empty(result)
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@docs_group('Resource clients')
|
|
187
|
+
class ResourceClient(ResourceClientBase):
|
|
188
|
+
"""Base class for synchronous resource clients."""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
base_url: str,
|
|
194
|
+
public_base_url: str,
|
|
195
|
+
http_client: HttpClient,
|
|
196
|
+
resource_path: str,
|
|
197
|
+
client_registry: ClientRegistry,
|
|
198
|
+
resource_id: str | None = None,
|
|
199
|
+
params: dict | None = None,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Initialize the resource client.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
base_url: API base URL.
|
|
205
|
+
public_base_url: Public CDN base URL.
|
|
206
|
+
http_client: HTTP client for making requests.
|
|
207
|
+
resource_path: Resource endpoint path (e.g., 'actors', 'datasets').
|
|
208
|
+
client_registry: Bundle of client classes for dependency injection.
|
|
209
|
+
resource_id: Optional resource ID for single-resource clients.
|
|
210
|
+
params: Optional default parameters for all requests.
|
|
211
|
+
"""
|
|
212
|
+
super().__init__(
|
|
213
|
+
base_url=base_url,
|
|
214
|
+
public_base_url=public_base_url,
|
|
215
|
+
http_client=http_client,
|
|
216
|
+
resource_path=resource_path,
|
|
217
|
+
client_registry=client_registry,
|
|
218
|
+
resource_id=resource_id,
|
|
219
|
+
params=params,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _get(self, *, timeout: Timeout) -> dict | None:
|
|
223
|
+
"""Perform a GET request for this resource, returning the parsed response or None if not found.
|
|
224
|
+
|
|
225
|
+
404s collapse to `None` only for ID-identified clients. Chained clients without a `resource_id`
|
|
226
|
+
(e.g. `run.dataset()`) propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
response = self._http_client.call(
|
|
230
|
+
url=self._build_url(),
|
|
231
|
+
method='GET',
|
|
232
|
+
params=self._build_params(),
|
|
233
|
+
timeout=timeout,
|
|
234
|
+
)
|
|
235
|
+
return response_to_dict(response)
|
|
236
|
+
except ApifyApiError as exc:
|
|
237
|
+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
|
|
241
|
+
"""Perform a PUT request to update this resource with the given fields."""
|
|
242
|
+
response = self._http_client.call(
|
|
243
|
+
url=self._build_url(),
|
|
244
|
+
method='PUT',
|
|
245
|
+
params=self._build_params(),
|
|
246
|
+
json=self._clean_json_payload(kwargs),
|
|
247
|
+
timeout=timeout,
|
|
248
|
+
)
|
|
249
|
+
return response_to_dict(response)
|
|
250
|
+
|
|
251
|
+
def _delete(self, *, timeout: Timeout) -> None:
|
|
252
|
+
"""Perform a DELETE request to delete this resource.
|
|
253
|
+
|
|
254
|
+
404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
|
|
255
|
+
`resource_id` propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
self._http_client.call(
|
|
259
|
+
url=self._build_url(),
|
|
260
|
+
method='DELETE',
|
|
261
|
+
params=self._build_params(),
|
|
262
|
+
timeout=timeout,
|
|
263
|
+
)
|
|
264
|
+
except ApifyApiError as exc:
|
|
265
|
+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
|
|
266
|
+
|
|
267
|
+
def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
|
|
268
|
+
"""Perform a GET request to list resources."""
|
|
269
|
+
response = self._http_client.call(
|
|
270
|
+
url=self._build_url(),
|
|
271
|
+
method='GET',
|
|
272
|
+
params=self._build_params(**kwargs),
|
|
273
|
+
timeout=timeout,
|
|
274
|
+
)
|
|
275
|
+
return response_to_dict(response)
|
|
276
|
+
|
|
277
|
+
def _create(self, *, timeout: Timeout, **kwargs: Any) -> dict:
|
|
278
|
+
"""Perform a POST request to create a resource."""
|
|
279
|
+
response = self._http_client.call(
|
|
280
|
+
url=self._build_url(),
|
|
281
|
+
method='POST',
|
|
282
|
+
params=self._build_params(),
|
|
283
|
+
json=self._clean_json_payload(kwargs),
|
|
284
|
+
timeout=timeout,
|
|
285
|
+
)
|
|
286
|
+
return response_to_dict(response)
|
|
287
|
+
|
|
288
|
+
def _get_or_create(
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
name: str | None = None,
|
|
292
|
+
resource_fields: dict | None = None,
|
|
293
|
+
timeout: Timeout,
|
|
294
|
+
) -> dict:
|
|
295
|
+
"""Perform a POST request to get or create a named resource."""
|
|
296
|
+
response = self._http_client.call(
|
|
297
|
+
url=self._build_url(),
|
|
298
|
+
method='POST',
|
|
299
|
+
params=self._build_params(name=name),
|
|
300
|
+
json=self._clean_json_payload(resource_fields) if resource_fields is not None else None,
|
|
301
|
+
timeout=timeout,
|
|
302
|
+
)
|
|
303
|
+
return response_to_dict(response)
|
|
304
|
+
|
|
305
|
+
def _wait_for_finish(
|
|
306
|
+
self,
|
|
307
|
+
*,
|
|
308
|
+
url: str,
|
|
309
|
+
params: dict,
|
|
310
|
+
timeout: Timeout,
|
|
311
|
+
wait_duration: timedelta | None = None,
|
|
312
|
+
) -> dict | None:
|
|
313
|
+
"""Wait synchronously for an Actor job (run or build) to finish.
|
|
314
|
+
|
|
315
|
+
Polls the job status until it reaches a terminal state or timeout.
|
|
316
|
+
Handles 404 errors gracefully (job might not exist yet in replicas).
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
url: Full URL to the job endpoint.
|
|
320
|
+
params: Base query parameters to include in each request.
|
|
321
|
+
timeout: Timeout for each individual HTTP request.
|
|
322
|
+
wait_duration: Maximum time to wait (None = indefinite).
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Job data dict when finished, or None if job doesn't exist after
|
|
326
|
+
DEFAULT_WAIT_WHEN_JOB_NOT_EXIST seconds.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ApifyApiError: If API returns errors other than 404.
|
|
330
|
+
"""
|
|
331
|
+
deadline = (datetime.now(UTC) + wait_duration) if wait_duration is not None else None
|
|
332
|
+
not_found_deadline: datetime | None = None
|
|
333
|
+
actor_job: dict = {}
|
|
334
|
+
|
|
335
|
+
while True:
|
|
336
|
+
if deadline is not None:
|
|
337
|
+
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
|
|
338
|
+
wait_for_finish = remaining_secs
|
|
339
|
+
else:
|
|
340
|
+
wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
response = self._http_client.call(
|
|
344
|
+
url=url,
|
|
345
|
+
method='GET',
|
|
346
|
+
params={**params, 'waitForFinish': wait_for_finish},
|
|
347
|
+
timeout=timeout,
|
|
348
|
+
)
|
|
349
|
+
result = response_to_dict(response)
|
|
350
|
+
actor_job = result.get('data') or {}
|
|
351
|
+
|
|
352
|
+
# Reset the not-found streak so a later transient 404 gets its own grace window.
|
|
353
|
+
not_found_deadline = None
|
|
354
|
+
|
|
355
|
+
is_terminal = actor_job.get('status') in _TERMINAL_STATUSES
|
|
356
|
+
is_timed_out = deadline is not None and datetime.now(UTC) >= deadline
|
|
357
|
+
|
|
358
|
+
if is_terminal or is_timed_out:
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
except ApifyApiError as exc:
|
|
362
|
+
catch_not_found_or_throw(exc)
|
|
363
|
+
|
|
364
|
+
now = datetime.now(UTC)
|
|
365
|
+
if deadline is not None and now >= deadline:
|
|
366
|
+
return None
|
|
367
|
+
if not_found_deadline is None:
|
|
368
|
+
not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
|
|
369
|
+
elif now > not_found_deadline:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
# It might take some time for database replicas to get up-to-date so sleep a bit before retrying
|
|
373
|
+
time.sleep(0.25)
|
|
374
|
+
|
|
375
|
+
return actor_job
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@docs_group('Resource clients')
|
|
379
|
+
class ResourceClientAsync(ResourceClientBase):
|
|
380
|
+
"""Base class for asynchronous resource clients."""
|
|
381
|
+
|
|
382
|
+
def __init__(
|
|
383
|
+
self,
|
|
384
|
+
*,
|
|
385
|
+
base_url: str,
|
|
386
|
+
public_base_url: str,
|
|
387
|
+
http_client: HttpClientAsync,
|
|
388
|
+
resource_path: str,
|
|
389
|
+
client_registry: ClientRegistryAsync,
|
|
390
|
+
resource_id: str | None = None,
|
|
391
|
+
params: dict | None = None,
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Initialize the resource client.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
base_url: API base URL.
|
|
397
|
+
public_base_url: Public CDN base URL.
|
|
398
|
+
http_client: HTTP client for making requests.
|
|
399
|
+
resource_path: Resource endpoint path (e.g., 'actors', 'datasets').
|
|
400
|
+
client_registry: Bundle of client classes for dependency injection.
|
|
401
|
+
resource_id: Optional resource ID for single-resource clients.
|
|
402
|
+
params: Optional default parameters for all requests.
|
|
403
|
+
"""
|
|
404
|
+
super().__init__(
|
|
405
|
+
base_url=base_url,
|
|
406
|
+
public_base_url=public_base_url,
|
|
407
|
+
http_client=http_client,
|
|
408
|
+
resource_path=resource_path,
|
|
409
|
+
client_registry=client_registry,
|
|
410
|
+
resource_id=resource_id,
|
|
411
|
+
params=params,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
async def _get(self, *, timeout: Timeout) -> dict | None:
|
|
415
|
+
"""Perform a GET request for this resource, returning the parsed response or None if not found.
|
|
416
|
+
|
|
417
|
+
404s collapse to `None` only for ID-identified clients. Chained clients without a `resource_id`
|
|
418
|
+
(e.g. `run.dataset()`) propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
response = await self._http_client.call(
|
|
422
|
+
url=self._build_url(),
|
|
423
|
+
method='GET',
|
|
424
|
+
params=self._build_params(),
|
|
425
|
+
timeout=timeout,
|
|
426
|
+
)
|
|
427
|
+
return response_to_dict(response)
|
|
428
|
+
except ApifyApiError as exc:
|
|
429
|
+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
async def _update(self, *, timeout: Timeout, **kwargs: Any) -> dict:
|
|
433
|
+
"""Perform a PUT request to update this resource with the given fields."""
|
|
434
|
+
response = await self._http_client.call(
|
|
435
|
+
url=self._build_url(),
|
|
436
|
+
method='PUT',
|
|
437
|
+
params=self._build_params(),
|
|
438
|
+
json=self._clean_json_payload(kwargs),
|
|
439
|
+
timeout=timeout,
|
|
440
|
+
)
|
|
441
|
+
return response_to_dict(response)
|
|
442
|
+
|
|
443
|
+
async def _delete(self, *, timeout: Timeout) -> None:
|
|
444
|
+
"""Perform a DELETE request to delete this resource.
|
|
445
|
+
|
|
446
|
+
404s are swallowed (idempotent DELETE) only for ID-identified clients. Chained clients without a
|
|
447
|
+
`resource_id` propagate `NotFoundError` - see `catch_not_found_for_resource_or_throw`.
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
await self._http_client.call(
|
|
451
|
+
url=self._build_url(),
|
|
452
|
+
method='DELETE',
|
|
453
|
+
params=self._build_params(),
|
|
454
|
+
timeout=timeout,
|
|
455
|
+
)
|
|
456
|
+
except ApifyApiError as exc:
|
|
457
|
+
catch_not_found_for_resource_or_throw(exc, self._resource_id)
|
|
458
|
+
|
|
459
|
+
async def _list(self, *, timeout: Timeout, **kwargs: Any) -> dict:
|
|
460
|
+
"""Perform a GET request to list resources."""
|
|
461
|
+
response = await self._http_client.call(
|
|
462
|
+
url=self._build_url(),
|
|
463
|
+
method='GET',
|
|
464
|
+
params=self._build_params(**kwargs),
|
|
465
|
+
timeout=timeout,
|
|
466
|
+
)
|
|
467
|
+
return response_to_dict(response)
|
|
468
|
+
|
|
469
|
+
async def _create(self, *, timeout: Timeout, **kwargs: Any) -> dict:
|
|
470
|
+
"""Perform a POST request to create a resource."""
|
|
471
|
+
response = await self._http_client.call(
|
|
472
|
+
url=self._build_url(),
|
|
473
|
+
method='POST',
|
|
474
|
+
params=self._build_params(),
|
|
475
|
+
json=self._clean_json_payload(kwargs),
|
|
476
|
+
timeout=timeout,
|
|
477
|
+
)
|
|
478
|
+
return response_to_dict(response)
|
|
479
|
+
|
|
480
|
+
async def _get_or_create(
|
|
481
|
+
self,
|
|
482
|
+
*,
|
|
483
|
+
name: str | None = None,
|
|
484
|
+
resource_fields: dict | None = None,
|
|
485
|
+
timeout: Timeout,
|
|
486
|
+
) -> dict:
|
|
487
|
+
"""Perform a POST request to get or create a named resource."""
|
|
488
|
+
response = await self._http_client.call(
|
|
489
|
+
url=self._build_url(),
|
|
490
|
+
method='POST',
|
|
491
|
+
params=self._build_params(name=name),
|
|
492
|
+
json=self._clean_json_payload(resource_fields) if resource_fields is not None else None,
|
|
493
|
+
timeout=timeout,
|
|
494
|
+
)
|
|
495
|
+
return response_to_dict(response)
|
|
496
|
+
|
|
497
|
+
async def _wait_for_finish(
|
|
498
|
+
self,
|
|
499
|
+
*,
|
|
500
|
+
url: str,
|
|
501
|
+
params: dict,
|
|
502
|
+
timeout: Timeout,
|
|
503
|
+
wait_duration: timedelta | None = None,
|
|
504
|
+
) -> dict | None:
|
|
505
|
+
"""Wait asynchronously for an Actor job (run or build) to finish.
|
|
506
|
+
|
|
507
|
+
Polls the job status until it reaches a terminal state or timeout.
|
|
508
|
+
Handles 404 errors gracefully (job might not exist yet in replicas).
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
url: Full URL to the job endpoint.
|
|
512
|
+
params: Base query parameters to include in each request.
|
|
513
|
+
timeout: Timeout for each individual HTTP request.
|
|
514
|
+
wait_duration: Maximum time to wait (None = indefinite).
|
|
515
|
+
|
|
516
|
+
Returns:
|
|
517
|
+
Job data dict when finished, or None if job doesn't exist after
|
|
518
|
+
DEFAULT_WAIT_WHEN_JOB_NOT_EXIST seconds.
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
ApifyApiError: If API returns errors other than 404.
|
|
522
|
+
"""
|
|
523
|
+
deadline = (datetime.now(UTC) + wait_duration) if wait_duration is not None else None
|
|
524
|
+
not_found_deadline: datetime | None = None
|
|
525
|
+
actor_job: dict = {}
|
|
526
|
+
|
|
527
|
+
while True:
|
|
528
|
+
if deadline is not None:
|
|
529
|
+
remaining_secs = max(0, int(to_seconds(deadline - datetime.now(UTC))))
|
|
530
|
+
wait_for_finish = remaining_secs
|
|
531
|
+
else:
|
|
532
|
+
wait_for_finish = to_seconds(DEFAULT_WAIT_FOR_FINISH, as_int=True)
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
response = await self._http_client.call(
|
|
536
|
+
url=url,
|
|
537
|
+
method='GET',
|
|
538
|
+
params={**params, 'waitForFinish': wait_for_finish},
|
|
539
|
+
timeout=timeout,
|
|
540
|
+
)
|
|
541
|
+
result = response_to_dict(response)
|
|
542
|
+
actor_job = result.get('data') or {}
|
|
543
|
+
|
|
544
|
+
# Reset the not-found streak so a later transient 404 gets its own grace window.
|
|
545
|
+
not_found_deadline = None
|
|
546
|
+
|
|
547
|
+
is_terminal = actor_job.get('status') in _TERMINAL_STATUSES
|
|
548
|
+
is_timed_out = deadline is not None and datetime.now(UTC) >= deadline
|
|
549
|
+
|
|
550
|
+
if is_terminal or is_timed_out:
|
|
551
|
+
break
|
|
552
|
+
|
|
553
|
+
except ApifyApiError as exc:
|
|
554
|
+
catch_not_found_or_throw(exc)
|
|
555
|
+
|
|
556
|
+
now = datetime.now(UTC)
|
|
557
|
+
if deadline is not None and now >= deadline:
|
|
558
|
+
return None
|
|
559
|
+
if not_found_deadline is None:
|
|
560
|
+
not_found_deadline = now + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST
|
|
561
|
+
elif now > not_found_deadline:
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
# It might take some time for database replicas to get up-to-date so sleep a bit before retrying
|
|
565
|
+
await asyncio.sleep(0.25)
|
|
566
|
+
|
|
567
|
+
return actor_job
|