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.

Files changed (65) hide show
  1. scrapely_client/__init__.py +30 -0
  2. scrapely_client/_charging.py +186 -0
  3. scrapely_client/_client_registry.py +136 -0
  4. scrapely_client/_consts.py +96 -0
  5. scrapely_client/_docs.py +37 -0
  6. scrapely_client/_literals.py +539 -0
  7. scrapely_client/_logging.py +180 -0
  8. scrapely_client/_models.py +4172 -0
  9. scrapely_client/_pagination.py +220 -0
  10. scrapely_client/_resource_clients/__init__.py +84 -0
  11. scrapely_client/_resource_clients/_resource_client.py +567 -0
  12. scrapely_client/_resource_clients/actor.py +1042 -0
  13. scrapely_client/_resource_clients/actor_collection.py +377 -0
  14. scrapely_client/_resource_clients/actor_env_var.py +154 -0
  15. scrapely_client/_resource_clients/actor_env_var_collection.py +165 -0
  16. scrapely_client/_resource_clients/actor_version.py +254 -0
  17. scrapely_client/_resource_clients/actor_version_collection.py +232 -0
  18. scrapely_client/_resource_clients/build.py +244 -0
  19. scrapely_client/_resource_clients/build_collection.py +164 -0
  20. scrapely_client/_resource_clients/dataset.py +1151 -0
  21. scrapely_client/_resource_clients/dataset_collection.py +244 -0
  22. scrapely_client/_resource_clients/key_value_store.py +906 -0
  23. scrapely_client/_resource_clients/key_value_store_collection.py +243 -0
  24. scrapely_client/_resource_clients/log.py +225 -0
  25. scrapely_client/_resource_clients/request_queue.py +1167 -0
  26. scrapely_client/_resource_clients/request_queue_collection.py +240 -0
  27. scrapely_client/_resource_clients/run.py +875 -0
  28. scrapely_client/_resource_clients/run_collection.py +226 -0
  29. scrapely_client/_resource_clients/schedule.py +243 -0
  30. scrapely_client/_resource_clients/schedule_collection.py +260 -0
  31. scrapely_client/_resource_clients/store_collection.py +242 -0
  32. scrapely_client/_resource_clients/task.py +793 -0
  33. scrapely_client/_resource_clients/task_collection.py +322 -0
  34. scrapely_client/_resource_clients/user.py +262 -0
  35. scrapely_client/_resource_clients/webhook.py +292 -0
  36. scrapely_client/_resource_clients/webhook_collection.py +281 -0
  37. scrapely_client/_resource_clients/webhook_dispatch.py +84 -0
  38. scrapely_client/_resource_clients/webhook_dispatch_collection.py +158 -0
  39. scrapely_client/_scrapely_client.py +837 -0
  40. scrapely_client/_statistics.py +29 -0
  41. scrapely_client/_status_message_watcher.py +197 -0
  42. scrapely_client/_streamed_log.py +250 -0
  43. scrapely_client/_typeddicts.py +408 -0
  44. scrapely_client/_utils/__init__.py +0 -0
  45. scrapely_client/_utils/crypto.py +87 -0
  46. scrapely_client/_utils/encoding.py +133 -0
  47. scrapely_client/_utils/errors.py +33 -0
  48. scrapely_client/_utils/http.py +137 -0
  49. scrapely_client/_utils/time.py +32 -0
  50. scrapely_client/_utils/try_import.py +61 -0
  51. scrapely_client/errors.py +190 -0
  52. scrapely_client/http_clients/__init__.py +10 -0
  53. scrapely_client/http_clients/_base.py +430 -0
  54. scrapely_client/http_clients/_impit.py +598 -0
  55. scrapely_client/http_compressors/__init__.py +18 -0
  56. scrapely_client/http_compressors/_base.py +25 -0
  57. scrapely_client/http_compressors/_brotli.py +38 -0
  58. scrapely_client/http_compressors/_gzip.py +38 -0
  59. scrapely_client/http_compressors/_resolve.py +38 -0
  60. scrapely_client/py.typed +0 -0
  61. scrapely_client/types.py +52 -0
  62. scrapely_client_python-1.0.0.dist-info/METADATA +244 -0
  63. scrapely_client_python-1.0.0.dist-info/RECORD +65 -0
  64. scrapely_client_python-1.0.0.dist-info/WHEEL +4 -0
  65. scrapely_client_python-1.0.0.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
+ """