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,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from scrapely_client._consts import (
|
|
8
|
+
ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES,
|
|
9
|
+
ALREADY_COMPRESSED_MEDIA_TYPES,
|
|
10
|
+
COMPRESSIBLE_MEDIA_TYPE_SUFFIXES,
|
|
11
|
+
COMPRESSIBLE_MEDIA_TYPES,
|
|
12
|
+
OVERRIDABLE_DEFAULT_HEADERS,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from scrapely_client.http_clients import HttpResponse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_safe_id(id: str) -> str:
|
|
20
|
+
"""Convert a resource ID to URL-safe format by replacing forward slashes with tildes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
id: The resource identifier in format `resource_id` or `username/resource_id`.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The resource identifier with `/` characters replaced by `~`.
|
|
27
|
+
"""
|
|
28
|
+
return id.replace('/', '~')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_path_segment(value: str) -> str:
|
|
32
|
+
"""Percent-encode a caller-supplied value so it stays a single URL path segment.
|
|
33
|
+
|
|
34
|
+
Without this, a value carrying `/`, `?` or `#` would restructure the URL it is interpolated into: it could
|
|
35
|
+
reach a different endpoint, append its own query parameters, or be silently truncated at a fragment.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
value: The value to place in a single path segment, for example a key-value store record key.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The percent-encoded value, which a URL parser can only read as one path segment.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If the value is empty, `.` or `..`, none of which can be carried in a path segment at all.
|
|
45
|
+
"""
|
|
46
|
+
# Encoding cannot save these three: an empty value leaves nothing but the separator, and a URL parser
|
|
47
|
+
# resolves a dot segment after percent-decoding, so `%2E%2E` collapses just like `..`. Either way the
|
|
48
|
+
# request lands on a parent endpoint, where `..` makes the same verb act on the whole resource.
|
|
49
|
+
if value in {'', '.', '..'}:
|
|
50
|
+
raise ValueError(f'"{value}" cannot be used as a URL path segment.')
|
|
51
|
+
|
|
52
|
+
return quote(value, safe='')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_compressible_content_type(content_type: str | None) -> bool:
|
|
56
|
+
"""Decide whether a request body with the given content type is worth compressing.
|
|
57
|
+
|
|
58
|
+
Images, audio, video and archives already carry their own compression. Running them through gzip or brotli
|
|
59
|
+
burns CPU, holds a second full copy of the body in memory, and usually produces output slightly larger than
|
|
60
|
+
the input. Formats that are raw despite such a media type, for example `image/bmp` or `audio/wav`, are still
|
|
61
|
+
compressed. A body with no content type is assumed to be compressible.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
content_type: The value of the `Content-Type` header, if any.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
`True` if the body should be compressed before it is sent.
|
|
68
|
+
"""
|
|
69
|
+
if not content_type:
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
# `Content-Type` is case-insensitive and may carry parameters, for example `text/plain; charset=utf-8`.
|
|
73
|
+
media_type = content_type.split(';', 1)[0].strip().lower()
|
|
74
|
+
|
|
75
|
+
if media_type in COMPRESSIBLE_MEDIA_TYPES or media_type.endswith(COMPRESSIBLE_MEDIA_TYPE_SUFFIXES):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
return not (
|
|
79
|
+
media_type in ALREADY_COMPRESSED_MEDIA_TYPES or media_type.startswith(ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def response_to_dict(response: HttpResponse) -> dict:
|
|
84
|
+
"""Parse the API response as a dictionary and validate its type.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
response: The HTTP response object from the API.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The parsed response as a dictionary.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If the response is not a dictionary.
|
|
94
|
+
"""
|
|
95
|
+
data = response.json()
|
|
96
|
+
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
raise ValueError(f'The response is not a dictionary. Got: {type(data).__name__}')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def response_to_list(response: HttpResponse) -> list:
|
|
104
|
+
"""Parse the API response as a list and validate its type.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
response: The HTTP response object from the API.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The parsed response as a list.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If the response is not a list.
|
|
114
|
+
"""
|
|
115
|
+
data = response.json()
|
|
116
|
+
|
|
117
|
+
if isinstance(data, list):
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
if isinstance(data, dict):
|
|
121
|
+
return [data]
|
|
122
|
+
|
|
123
|
+
raise ValueError(f'The response is not a list. Got: {type(data).__name__}')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_custom_headers(class_name: str, headers: dict[str, str]) -> None:
|
|
127
|
+
"""Warn if custom headers override important default headers."""
|
|
128
|
+
overwrite_headers = [key for key in headers if key.title() in OVERRIDABLE_DEFAULT_HEADERS]
|
|
129
|
+
|
|
130
|
+
if overwrite_headers:
|
|
131
|
+
warnings.warn(
|
|
132
|
+
f'{", ".join(overwrite_headers)} headers of {class_name} was overridden with an '
|
|
133
|
+
'explicit value. A wrong header value can lead to API errors, it is recommended to use the default '
|
|
134
|
+
f'value for following headers: {", ".join(OVERRIDABLE_DEFAULT_HEADERS)}.',
|
|
135
|
+
category=UserWarning,
|
|
136
|
+
stacklevel=3,
|
|
137
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Literal, overload
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@overload
|
|
10
|
+
def to_seconds(td: None, *, as_int: bool = ...) -> None: ...
|
|
11
|
+
@overload
|
|
12
|
+
def to_seconds(td: timedelta) -> float: ...
|
|
13
|
+
@overload
|
|
14
|
+
def to_seconds(td: timedelta, *, as_int: Literal[True]) -> int: ...
|
|
15
|
+
@overload
|
|
16
|
+
def to_seconds(td: timedelta, *, as_int: Literal[False]) -> float: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_seconds(td: timedelta | None, *, as_int: bool = False) -> float | int | None:
|
|
20
|
+
"""Convert timedelta to seconds.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
td: The timedelta to convert, or None.
|
|
24
|
+
as_int: If True, round and return as int. Defaults to False.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The total seconds as a float (or int if as_int=True), or None if input is None.
|
|
28
|
+
"""
|
|
29
|
+
if td is None:
|
|
30
|
+
return None
|
|
31
|
+
seconds = td.total_seconds()
|
|
32
|
+
return int(seconds) if as_int else seconds
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ImportState:
|
|
16
|
+
"""Describe whether an optional import succeeded."""
|
|
17
|
+
|
|
18
|
+
available: bool = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def try_import(module_name: str, *symbol_names: str, dependency_name: str) -> Iterator[ImportState]:
|
|
23
|
+
"""Context manager to attempt importing symbols into a module.
|
|
24
|
+
|
|
25
|
+
If the named optional dependency is missing, the symbols are replaced with `FailedImport` objects. Import errors
|
|
26
|
+
caused by the importing module itself or by another dependency are propagated instead of being masked.
|
|
27
|
+
"""
|
|
28
|
+
state = ImportState()
|
|
29
|
+
try:
|
|
30
|
+
yield state
|
|
31
|
+
except ModuleNotFoundError as exc:
|
|
32
|
+
if exc.name != dependency_name:
|
|
33
|
+
raise
|
|
34
|
+
state.available = False
|
|
35
|
+
for symbol_name in symbol_names:
|
|
36
|
+
setattr(sys.modules[module_name], symbol_name, FailedImport(exc.args[0]))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def install_import_hook(module_name: str) -> None:
|
|
40
|
+
"""Install an import hook for a specified module."""
|
|
41
|
+
sys.modules[module_name].__class__ = ImportWrapper
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FailedImport:
|
|
46
|
+
"""Represent a placeholder for a failed import."""
|
|
47
|
+
|
|
48
|
+
message: str
|
|
49
|
+
"""The error message associated with the failed import."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ImportWrapper(ModuleType):
|
|
53
|
+
"""A wrapper class for modules to handle attribute access for failed imports."""
|
|
54
|
+
|
|
55
|
+
def __getattribute__(self, name: str) -> Any:
|
|
56
|
+
result = super().__getattribute__(name)
|
|
57
|
+
|
|
58
|
+
if isinstance(result, FailedImport):
|
|
59
|
+
raise ImportError(result.message) # noqa: TRY004
|
|
60
|
+
|
|
61
|
+
return result
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from scrapely_client._docs import docs_group
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from scrapely_client.http_clients import HttpResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@docs_group('Errors')
|
|
15
|
+
class ApifyClientError(Exception):
|
|
16
|
+
"""Base class for all Scrapely API client errors."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@docs_group('Errors')
|
|
20
|
+
class ApifyApiError(ApifyClientError):
|
|
21
|
+
"""Error raised when the Scrapely API returns an error response.
|
|
22
|
+
|
|
23
|
+
Instantiating `ApifyApiError` dispatches to the subclass matching the HTTP status code (e.g. 404 → `NotFoundError`,
|
|
24
|
+
any 5xx → `ServerError`). Unmapped statuses stay on `ApifyApiError`. Existing `except ApifyApiError` handlers keep
|
|
25
|
+
working because every subclass inherits from this class.
|
|
26
|
+
|
|
27
|
+
The `type`, `message` and `data` fields from the response body are exposed for inspection but are treated as
|
|
28
|
+
non-authoritative metadata — dispatch is driven by the status code only.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
message: The error message from the API response.
|
|
32
|
+
type: The error type identifier from the API response (e.g. `record-not-found`).
|
|
33
|
+
status_code: The HTTP status code of the error response.
|
|
34
|
+
attempt: The attempt number when the error was raised.
|
|
35
|
+
http_method: The HTTP method of the failed request.
|
|
36
|
+
data: Additional error data from the API response.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
# Subclasses in `_STATUS_TO_CLASS` must keep the `(response, attempt, *, method='GET')` constructor signature —
|
|
40
|
+
# `__new__` forwards those arguments verbatim.
|
|
41
|
+
|
|
42
|
+
def __new__(cls, response: HttpResponse, attempt: int, *, method: str = 'GET') -> Self: # noqa: ARG004
|
|
43
|
+
"""Dispatch to the subclass matching the response's HTTP status code, if any."""
|
|
44
|
+
instance = super().__new__(cls)
|
|
45
|
+
if cls is ApifyApiError:
|
|
46
|
+
status = response.status_code
|
|
47
|
+
mapped = _STATUS_TO_CLASS.get(status)
|
|
48
|
+
if mapped is None and status >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
49
|
+
mapped = ServerError
|
|
50
|
+
if mapped is not None:
|
|
51
|
+
# Retagging is runtime-equivalent to allocating `mapped` directly, but keeps the return type `Self`.
|
|
52
|
+
instance.__class__ = mapped
|
|
53
|
+
return instance
|
|
54
|
+
|
|
55
|
+
def __init__(self, response: HttpResponse, attempt: int, *, method: str = 'GET') -> None:
|
|
56
|
+
"""Initialize the API error from a failed response.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
response: The failed HTTP response from the Scrapely API.
|
|
60
|
+
attempt: The attempt number when the request failed (1-indexed).
|
|
61
|
+
method: The HTTP method of the failed request.
|
|
62
|
+
"""
|
|
63
|
+
payload = self._extract_error_payload(response)
|
|
64
|
+
|
|
65
|
+
self.message: str | None = f'Unexpected error: {response.text}'
|
|
66
|
+
self.type: str | None = None
|
|
67
|
+
self.data = dict[str, str]()
|
|
68
|
+
|
|
69
|
+
if payload is not None:
|
|
70
|
+
self.message = payload.get('message', self.message)
|
|
71
|
+
self.type = payload.get('type')
|
|
72
|
+
if 'data' in payload:
|
|
73
|
+
self.data = payload['data']
|
|
74
|
+
|
|
75
|
+
super().__init__(self.message)
|
|
76
|
+
|
|
77
|
+
self.status_code = response.status_code
|
|
78
|
+
self.attempt = attempt
|
|
79
|
+
self.http_method = method
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _extract_error_payload(response: HttpResponse) -> dict[str, Any] | None:
|
|
83
|
+
"""Return the `error` dict from the response body, or None if absent or unparsable."""
|
|
84
|
+
try:
|
|
85
|
+
data = response.json()
|
|
86
|
+
except ValueError:
|
|
87
|
+
return None
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
return None
|
|
90
|
+
error = data.get('error')
|
|
91
|
+
# Scrapely's API consistently returns { error: { type, message } }, but some edge/proxy
|
|
92
|
+
# responses return a plain string (e.g. { "error": "Unauthorized" }).
|
|
93
|
+
if isinstance(error, str):
|
|
94
|
+
return {'message': error}
|
|
95
|
+
return error if isinstance(error, dict) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@docs_group('Errors')
|
|
99
|
+
class InvalidRequestError(ApifyApiError):
|
|
100
|
+
"""Raised when the Scrapely API returns an HTTP 400 Bad Request response."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@docs_group('Errors')
|
|
104
|
+
class UnauthorizedError(ApifyApiError):
|
|
105
|
+
"""Raised when the Scrapely API returns an HTTP 401 Unauthorized response."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@docs_group('Errors')
|
|
109
|
+
class ForbiddenError(ApifyApiError):
|
|
110
|
+
"""Raised when the Scrapely API returns an HTTP 403 Forbidden response."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@docs_group('Errors')
|
|
114
|
+
class NotFoundError(ApifyApiError):
|
|
115
|
+
"""Raised when the Scrapely API returns an HTTP 404 Not Found response."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@docs_group('Errors')
|
|
119
|
+
class ConflictError(ApifyApiError):
|
|
120
|
+
"""Raised when the Scrapely API returns an HTTP 409 Conflict response."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@docs_group('Errors')
|
|
124
|
+
class RateLimitError(ApifyApiError):
|
|
125
|
+
"""Raised when the Scrapely API returns an HTTP 429 Too Many Requests response.
|
|
126
|
+
|
|
127
|
+
Rate-limited requests are retried automatically; this error is only raised after all retry attempts have been
|
|
128
|
+
exhausted.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@docs_group('Errors')
|
|
133
|
+
class ServerError(ApifyApiError):
|
|
134
|
+
"""Raised when the Scrapely API returns an HTTP 5xx response.
|
|
135
|
+
|
|
136
|
+
Server errors are retried automatically; this error is only raised after all retry attempts have been exhausted.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@docs_group('Errors')
|
|
141
|
+
class InvalidResponseBodyError(ApifyClientError):
|
|
142
|
+
"""Error raised when a response body cannot be parsed.
|
|
143
|
+
|
|
144
|
+
This occurs when the API returns a body that does not match its content type, for example a malformed JSON
|
|
145
|
+
document. It is raised on a response the client already accepted, so it is not retried - a transfer that breaks
|
|
146
|
+
mid-body surfaces as a transport error inside the retry loop instead.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, response: HttpResponse) -> None:
|
|
150
|
+
"""Initialize the error from an unparsable response.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
response: The HTTP response whose body could not be parsed.
|
|
154
|
+
"""
|
|
155
|
+
super().__init__('Response body could not be parsed')
|
|
156
|
+
|
|
157
|
+
self.code = 'invalid-response-body'
|
|
158
|
+
self.response = response
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Scrapely-named aliases for the Apify-compatible error classes. The Apify names are part of
|
|
162
|
+
# the client's backward-compatibility contract (code written against the Apify SDK keeps
|
|
163
|
+
# working); these aliases let Scrapely-native code use the platform name.
|
|
164
|
+
ScrapelyApiError = ApifyApiError
|
|
165
|
+
ScrapelyClientError = ApifyClientError
|
|
166
|
+
|
|
167
|
+
_STATUS_TO_CLASS: dict[int, type[ApifyApiError]] = {
|
|
168
|
+
400: InvalidRequestError,
|
|
169
|
+
401: UnauthorizedError,
|
|
170
|
+
403: ForbiddenError,
|
|
171
|
+
404: NotFoundError,
|
|
172
|
+
409: ConflictError,
|
|
173
|
+
429: RateLimitError,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
'ApifyApiError',
|
|
179
|
+
'ApifyClientError',
|
|
180
|
+
'ConflictError',
|
|
181
|
+
'ForbiddenError',
|
|
182
|
+
'InvalidRequestError',
|
|
183
|
+
'InvalidResponseBodyError',
|
|
184
|
+
'NotFoundError',
|
|
185
|
+
'RateLimitError',
|
|
186
|
+
'ScrapelyApiError',
|
|
187
|
+
'ScrapelyClientError',
|
|
188
|
+
'ServerError',
|
|
189
|
+
'UnauthorizedError',
|
|
190
|
+
]
|