scrapely-client-python 3.1.3__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-3.1.3.dist-info/METADATA +269 -0
- scrapely_client_python-3.1.3.dist-info/RECORD +65 -0
- scrapely_client_python-3.1.3.dist-info/WHEEL +4 -0
- scrapely_client_python-3.1.3.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as jsonlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
from importlib import metadata
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
# `Protocol` comes from `typing_extensions`, not `typing`, because its runtime `isinstance` check looks attributes
|
|
14
|
+
# up statically. The `typing` implementation on Python 3.11 calls `hasattr`, which evaluates properties. On an
|
|
15
|
+
# unread streaming response, that either raises or silently buffers the whole body.
|
|
16
|
+
from typing_extensions import Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from scrapely_client._consts import (
|
|
19
|
+
DEFAULT_MAX_RETRIES,
|
|
20
|
+
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
|
|
21
|
+
DEFAULT_TIMEOUT_LONG,
|
|
22
|
+
DEFAULT_TIMEOUT_MAX,
|
|
23
|
+
DEFAULT_TIMEOUT_MEDIUM,
|
|
24
|
+
DEFAULT_TIMEOUT_SHORT,
|
|
25
|
+
MIN_COMPRESSION_SIZE,
|
|
26
|
+
)
|
|
27
|
+
from scrapely_client._docs import docs_group
|
|
28
|
+
from scrapely_client._logging import LoggerOnce, logger_name
|
|
29
|
+
from scrapely_client._statistics import ClientStatistics
|
|
30
|
+
from scrapely_client._utils.http import is_compressible_content_type
|
|
31
|
+
from scrapely_client._utils.time import to_seconds
|
|
32
|
+
from scrapely_client.http_compressors._gzip import GzipHttpCompressor
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
36
|
+
|
|
37
|
+
from scrapely_client.http_compressors._base import HttpCompressor
|
|
38
|
+
from scrapely_client.types import JsonSerializable, Timeout
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(logger_name)
|
|
41
|
+
logger_once = LoggerOnce(logger)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@docs_group('HTTP clients')
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class HttpResponse(Protocol):
|
|
47
|
+
"""Protocol for HTTP response objects returned by HTTP clients.
|
|
48
|
+
|
|
49
|
+
Any object that has the required attributes and methods can be used as an HTTP response
|
|
50
|
+
(e.g., `impit.Response`). This enables custom HTTP client implementations to return
|
|
51
|
+
their own response types.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def status_code(self) -> int:
|
|
56
|
+
"""HTTP status code of the response."""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def text(self) -> str:
|
|
60
|
+
"""Response body decoded as text."""
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def content(self) -> bytes:
|
|
64
|
+
"""Raw response body as bytes."""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def headers(self) -> Mapping[str, str]:
|
|
68
|
+
"""Response headers as a mapping."""
|
|
69
|
+
|
|
70
|
+
def json(self) -> Any:
|
|
71
|
+
"""Parse response body as JSON."""
|
|
72
|
+
|
|
73
|
+
def read(self) -> bytes:
|
|
74
|
+
"""Read the entire response body."""
|
|
75
|
+
|
|
76
|
+
async def aread(self) -> bytes:
|
|
77
|
+
"""Read the entire response body asynchronously."""
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
"""Close the response and release the connection."""
|
|
81
|
+
|
|
82
|
+
async def aclose(self) -> None:
|
|
83
|
+
"""Close the response and release the connection asynchronously."""
|
|
84
|
+
|
|
85
|
+
def iter_bytes(self) -> Iterator[bytes]:
|
|
86
|
+
"""Iterate over the response body in bytes chunks."""
|
|
87
|
+
|
|
88
|
+
def aiter_bytes(self) -> AsyncIterator[bytes]:
|
|
89
|
+
"""Iterate over the response body in bytes chunks asynchronously."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@docs_group('HTTP clients')
|
|
93
|
+
class HttpClientBase:
|
|
94
|
+
"""Shared configuration and utilities for HTTP clients.
|
|
95
|
+
|
|
96
|
+
Provides common functionality for both sync and async HTTP clients including:
|
|
97
|
+
header construction, parameter parsing, request body preparation, URL building,
|
|
98
|
+
and timeout calculation.
|
|
99
|
+
|
|
100
|
+
Subclasses should call `super().__init__()` to initialize shared configuration.
|
|
101
|
+
The helper methods are then available for use in the `call()` implementation.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
token: str | None = None,
|
|
108
|
+
timeout_short: timedelta = DEFAULT_TIMEOUT_SHORT,
|
|
109
|
+
timeout_medium: timedelta = DEFAULT_TIMEOUT_MEDIUM,
|
|
110
|
+
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
|
|
111
|
+
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
|
|
112
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
113
|
+
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
|
|
114
|
+
statistics: ClientStatistics | None = None,
|
|
115
|
+
headers: dict[str, str] | None = None,
|
|
116
|
+
http_compressor: HttpCompressor | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize the HTTP client base.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
token: Scrapely API token for authentication.
|
|
122
|
+
timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...).
|
|
123
|
+
timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...).
|
|
124
|
+
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
|
|
125
|
+
timeout_max: Maximum timeout cap for any single request attempt, including tier and per-call timeouts.
|
|
126
|
+
max_retries: Maximum number of retries for failed requests.
|
|
127
|
+
min_delay_between_retries: Minimum delay between retries.
|
|
128
|
+
statistics: Statistics tracker for API calls. Created automatically if not provided.
|
|
129
|
+
headers: Additional HTTP headers to include in all requests.
|
|
130
|
+
http_compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`.
|
|
131
|
+
"""
|
|
132
|
+
self._http_compressor = http_compressor if http_compressor is not None else GzipHttpCompressor()
|
|
133
|
+
self._timeout_short = timeout_short
|
|
134
|
+
self._timeout_medium = timeout_medium
|
|
135
|
+
self._timeout_long = timeout_long
|
|
136
|
+
self._timeout_max = timeout_max
|
|
137
|
+
self._max_retries = max_retries
|
|
138
|
+
self._min_delay_between_retries = min_delay_between_retries
|
|
139
|
+
self._statistics = statistics or ClientStatistics()
|
|
140
|
+
|
|
141
|
+
# Build default headers.
|
|
142
|
+
default_headers: dict[str, str] = {'Accept': 'application/json, */*'}
|
|
143
|
+
|
|
144
|
+
workflow_key = os.getenv('APIFY_WORKFLOW_KEY')
|
|
145
|
+
if workflow_key is not None:
|
|
146
|
+
default_headers['X-Apify-Workflow-Key'] = workflow_key
|
|
147
|
+
|
|
148
|
+
is_at_home = str('APIFY_IS_AT_HOME' in os.environ).lower()
|
|
149
|
+
python_version = '.'.join([str(x) for x in sys.version_info[:3]])
|
|
150
|
+
try:
|
|
151
|
+
client_version = metadata.version('scrapely-client')
|
|
152
|
+
except metadata.PackageNotFoundError:
|
|
153
|
+
client_version = '0.0.0'
|
|
154
|
+
|
|
155
|
+
user_agent = f'ScrapelyClient/{client_version} ({sys.platform}; Python/{python_version}); isAtHome/{is_at_home}'
|
|
156
|
+
default_headers['User-Agent'] = user_agent
|
|
157
|
+
|
|
158
|
+
if token is not None:
|
|
159
|
+
default_headers['Authorization'] = f'Bearer {token}'
|
|
160
|
+
|
|
161
|
+
self._headers = self._merge_headers(default_headers, headers)
|
|
162
|
+
|
|
163
|
+
def set_default_authorization(self, token: str) -> None:
|
|
164
|
+
"""Set the `Authorization` header from the token, unless an authorization header is already configured.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
token: The Scrapely API token to set as the `Bearer` authorization.
|
|
168
|
+
"""
|
|
169
|
+
if self._get_header(self._headers, 'authorization') is None:
|
|
170
|
+
self._headers['Authorization'] = f'Bearer {token}'
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _merge_headers(base: dict[str, str] | None, override: dict[str, str] | None) -> dict[str, str]:
|
|
174
|
+
"""Merge two header dicts, treating header names case-insensitively.
|
|
175
|
+
|
|
176
|
+
A header from `override` replaces a same-named header in `base` regardless of the casing
|
|
177
|
+
of either name, and keeps the casing it was passed with.
|
|
178
|
+
"""
|
|
179
|
+
merged = dict(base) if base else {}
|
|
180
|
+
for key, value in (override or {}).items():
|
|
181
|
+
for existing_key in [k for k in merged if k.lower() == key.lower()]:
|
|
182
|
+
del merged[existing_key]
|
|
183
|
+
merged[key] = value
|
|
184
|
+
return merged
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _get_header(headers: dict[str, str], name: str) -> str | None:
|
|
188
|
+
"""Look up a header value by name, treated case-insensitively. Returns `None` if the header is not set."""
|
|
189
|
+
return next((value for key, value in headers.items() if key.lower() == name.lower()), None)
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
193
|
+
"""Convert request parameters to Scrapely API-compatible formats.
|
|
194
|
+
|
|
195
|
+
Converts booleans to 'false'/'true', lists to comma-separated strings, datetimes to ISO 8601 Zulu format.
|
|
196
|
+
"""
|
|
197
|
+
if params is None:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
parsed_params: dict[str, Any] = {}
|
|
201
|
+
for key, value in params.items():
|
|
202
|
+
if isinstance(value, bool):
|
|
203
|
+
parsed_params[key] = (str(value)).lower()
|
|
204
|
+
elif isinstance(value, list):
|
|
205
|
+
parsed_params[key] = ','.join(value)
|
|
206
|
+
elif isinstance(value, datetime):
|
|
207
|
+
# Treat a naive datetime as UTC; `.astimezone()` would otherwise assume the host's local tz.
|
|
208
|
+
aware = value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
|
209
|
+
utc_aware_dt = aware.astimezone(UTC)
|
|
210
|
+
iso_str = utc_aware_dt.isoformat(timespec='milliseconds')
|
|
211
|
+
parsed_params[key] = iso_str.replace('+00:00', 'Z')
|
|
212
|
+
elif value is not None:
|
|
213
|
+
parsed_params[key] = value
|
|
214
|
+
|
|
215
|
+
return parsed_params
|
|
216
|
+
|
|
217
|
+
def _compute_timeout(self, timeout: Timeout, *, attempt: int) -> int | float | None:
|
|
218
|
+
"""Resolve a timeout tier and compute the timeout for a request attempt with exponential increase.
|
|
219
|
+
|
|
220
|
+
For `no_timeout`, returns `None` to indicate no timeout. For tier literals and explicit `timedelta` values,
|
|
221
|
+
doubles the timeout with each attempt but caps at `timeout_max`. A base timeout above `timeout_max` is
|
|
222
|
+
capped too, which warns once per timeout kind since the requested value does not take effect in full.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
timeout: The timeout specification to resolve (tier literal or explicit `timedelta`).
|
|
226
|
+
attempt: Current attempt number (1-indexed).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Timeout in seconds, or `None` for no timeout.
|
|
230
|
+
"""
|
|
231
|
+
if timeout == 'no_timeout':
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
if timeout == 'short':
|
|
235
|
+
resolved = self._timeout_short
|
|
236
|
+
elif timeout == 'medium':
|
|
237
|
+
resolved = self._timeout_medium
|
|
238
|
+
elif timeout == 'long':
|
|
239
|
+
resolved = self._timeout_long
|
|
240
|
+
else:
|
|
241
|
+
resolved = timeout
|
|
242
|
+
|
|
243
|
+
if resolved > self._timeout_max:
|
|
244
|
+
# Keyed per timeout kind, so retries and repeated calls do not spam the log with the same warning.
|
|
245
|
+
logger_once.log(
|
|
246
|
+
f'The requested timeout of {to_seconds(resolved)}s exceeds `timeout_max` '
|
|
247
|
+
f'({to_seconds(self._timeout_max)}s) and is capped at it. Raise `timeout_max` on the client '
|
|
248
|
+
'to allow longer request timeouts.',
|
|
249
|
+
key=f'timeout-capped-{timeout if isinstance(timeout, str) else "explicit"}',
|
|
250
|
+
level=logging.WARNING,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
new_timeout = min(resolved * (2 ** (attempt - 1)), self._timeout_max)
|
|
254
|
+
return to_seconds(new_timeout)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _is_body_worth_compressing(data: str | bytes | bytearray | None) -> bool:
|
|
258
|
+
"""Whether this body clears the size threshold `_prepare_request_call` compresses at, cheaply.
|
|
259
|
+
|
|
260
|
+
Below the threshold nothing is ever compressed. At or above it the content type and a caller-supplied
|
|
261
|
+
`Content-Encoding` still decide, but checking those here would buy nothing - a body that turns out to be
|
|
262
|
+
already encoded only wastes the thread hop this answer guards.
|
|
263
|
+
|
|
264
|
+
The threshold is measured on encoded bytes, so a character count alone cannot decide a `str`. It is a
|
|
265
|
+
lower bound, so a `str` long enough in characters is long enough in bytes too. Below that the encoded
|
|
266
|
+
length decides, and the body is then under 4 KiB, so encoding it here is cheap.
|
|
267
|
+
"""
|
|
268
|
+
if isinstance(data, str):
|
|
269
|
+
return len(data) >= MIN_COMPRESSION_SIZE or len(data.encode('utf-8')) >= MIN_COMPRESSION_SIZE
|
|
270
|
+
if isinstance(data, (bytes, bytearray)):
|
|
271
|
+
return len(data) >= MIN_COMPRESSION_SIZE
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
def _prepare_request_call(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
headers: dict[str, str] | None = None,
|
|
278
|
+
params: dict[str, Any] | None = None,
|
|
279
|
+
data: str | bytes | bytearray | None = None,
|
|
280
|
+
json: JsonSerializable | None = None,
|
|
281
|
+
) -> tuple[dict[str, str], dict[str, Any] | None, bytes | None]:
|
|
282
|
+
"""Prepare headers, params, and body for an HTTP request.
|
|
283
|
+
|
|
284
|
+
Merges the client's default headers (including authorization) with per-request headers and serializes a
|
|
285
|
+
JSON body. Header names are treated case-insensitively and per-request values win over the client
|
|
286
|
+
defaults. For JSON bodies, a `Content-Type` header is set unless the caller supplied one.
|
|
287
|
+
|
|
288
|
+
The body is compressed unless a `Content-Encoding` header is already set, the body is smaller than
|
|
289
|
+
`MIN_COMPRESSION_SIZE`, or its content type says the payload is already compressed. A caller-supplied
|
|
290
|
+
`Content-Encoding` is forwarded verbatim, which is how a pre-encoded body is uploaded - including one in
|
|
291
|
+
an encoding the client ships no compressor for. `Content-Encoding: identity` therefore opts a single
|
|
292
|
+
request out of compression.
|
|
293
|
+
"""
|
|
294
|
+
if json is not None and data is not None:
|
|
295
|
+
raise ValueError('Cannot pass both "json" and "data" parameters at the same time!')
|
|
296
|
+
|
|
297
|
+
headers = self._merge_headers(self._headers, headers)
|
|
298
|
+
|
|
299
|
+
# Dump JSON data to a string so it can be sent as a request body.
|
|
300
|
+
if json is not None:
|
|
301
|
+
data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')
|
|
302
|
+
if self._get_header(headers, 'content-type') is None:
|
|
303
|
+
headers['Content-Type'] = 'application/json'
|
|
304
|
+
|
|
305
|
+
if isinstance(data, (str, bytes, bytearray)):
|
|
306
|
+
if isinstance(data, str):
|
|
307
|
+
data = data.encode('utf-8')
|
|
308
|
+
elif isinstance(data, bytearray):
|
|
309
|
+
data = bytes(data)
|
|
310
|
+
|
|
311
|
+
# A caller-supplied encoding says the body arrives already encoded, so compressing it here would
|
|
312
|
+
# both mislabel it and waste the work.
|
|
313
|
+
if (
|
|
314
|
+
self._get_header(headers, 'content-encoding') is None
|
|
315
|
+
and len(data) >= MIN_COMPRESSION_SIZE
|
|
316
|
+
and is_compressible_content_type(self._get_header(headers, 'content-type'))
|
|
317
|
+
):
|
|
318
|
+
data = self._http_compressor.compress(data)
|
|
319
|
+
headers = self._merge_headers(headers, {'Content-Encoding': self._http_compressor.content_encoding})
|
|
320
|
+
|
|
321
|
+
return (headers, self._parse_params(params), data)
|
|
322
|
+
|
|
323
|
+
def _build_url_with_params(self, url: str, *, params: dict[str, Any] | None = None) -> str:
|
|
324
|
+
"""Build a URL with query parameters appended. List values are expanded into multiple key=value pairs."""
|
|
325
|
+
if not params:
|
|
326
|
+
return url
|
|
327
|
+
|
|
328
|
+
param_pairs = list[tuple[str, str]]()
|
|
329
|
+
for key, value in params.items():
|
|
330
|
+
if isinstance(value, list):
|
|
331
|
+
param_pairs.extend((key, str(v)) for v in value)
|
|
332
|
+
else:
|
|
333
|
+
param_pairs.append((key, str(value)))
|
|
334
|
+
|
|
335
|
+
query_string = urlencode(param_pairs)
|
|
336
|
+
|
|
337
|
+
return f'{url}?{query_string}'
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@docs_group('HTTP clients')
|
|
341
|
+
class HttpClient(HttpClientBase, ABC):
|
|
342
|
+
"""Abstract base class for synchronous HTTP clients used by `ApifyClient`.
|
|
343
|
+
|
|
344
|
+
Extend this class to create a custom synchronous HTTP client. Override the `call` method
|
|
345
|
+
with your implementation. Helper methods from the base class are available for request
|
|
346
|
+
preparation, URL building, and parameter parsing.
|
|
347
|
+
|
|
348
|
+
Implementations must send the client's default headers from `self._headers` with every request,
|
|
349
|
+
otherwise the `Authorization` header never reaches the API. The `_prepare_request_call` helper
|
|
350
|
+
merges them into the per-request headers automatically.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
@abstractmethod
|
|
354
|
+
def call(
|
|
355
|
+
self,
|
|
356
|
+
*,
|
|
357
|
+
method: str,
|
|
358
|
+
url: str,
|
|
359
|
+
headers: dict[str, str] | None = None,
|
|
360
|
+
params: dict[str, Any] | None = None,
|
|
361
|
+
data: str | bytes | bytearray | None = None,
|
|
362
|
+
json: Any = None,
|
|
363
|
+
stream: bool | None = None,
|
|
364
|
+
timeout: Timeout = 'medium',
|
|
365
|
+
) -> HttpResponse:
|
|
366
|
+
"""Make an HTTP request.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.).
|
|
370
|
+
url: Full URL to make the request to.
|
|
371
|
+
headers: Additional headers to include in this request.
|
|
372
|
+
params: Query parameters to append to the URL.
|
|
373
|
+
data: Raw request body data. Cannot be used together with json.
|
|
374
|
+
json: JSON-serializable data for the request body. Cannot be used together with data.
|
|
375
|
+
stream: Whether to stream the response body.
|
|
376
|
+
timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for
|
|
377
|
+
preconfigured timeouts. A `timedelta` overrides it for this call (capped at `timeout_max`), and
|
|
378
|
+
`no_timeout` disables the timeout entirely.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
The HTTP response object.
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
|
|
385
|
+
ValueError: If both json and data are provided.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@docs_group('HTTP clients')
|
|
390
|
+
class HttpClientAsync(HttpClientBase, ABC):
|
|
391
|
+
"""Abstract base class for asynchronous HTTP clients used by `ApifyClientAsync`.
|
|
392
|
+
|
|
393
|
+
Extend this class to create a custom asynchronous HTTP client. See `HttpClient`
|
|
394
|
+
for details on the expected behavior.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
@abstractmethod
|
|
398
|
+
async def call(
|
|
399
|
+
self,
|
|
400
|
+
*,
|
|
401
|
+
method: str,
|
|
402
|
+
url: str,
|
|
403
|
+
headers: dict[str, str] | None = None,
|
|
404
|
+
params: dict[str, Any] | None = None,
|
|
405
|
+
data: str | bytes | bytearray | None = None,
|
|
406
|
+
json: Any = None,
|
|
407
|
+
stream: bool | None = None,
|
|
408
|
+
timeout: Timeout = 'medium',
|
|
409
|
+
) -> HttpResponse:
|
|
410
|
+
"""Make an HTTP request.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
method: HTTP method (GET, POST, PUT, DELETE, etc.).
|
|
414
|
+
url: Full URL to make the request to.
|
|
415
|
+
headers: Additional headers to include in this request.
|
|
416
|
+
params: Query parameters to append to the URL.
|
|
417
|
+
data: Raw request body data. Cannot be used together with json.
|
|
418
|
+
json: JSON-serializable data for the request body. Cannot be used together with data.
|
|
419
|
+
stream: Whether to stream the response body.
|
|
420
|
+
timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for
|
|
421
|
+
preconfigured timeouts. A `timedelta` overrides it for this call (capped at `timeout_max`), and
|
|
422
|
+
`no_timeout` disables the timeout entirely.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The HTTP response object.
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
|
|
429
|
+
ValueError: If both json and data are provided.
|
|
430
|
+
"""
|