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.

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-3.1.3.dist-info/METADATA +269 -0
  63. scrapely_client_python-3.1.3.dist-info/RECORD +65 -0
  64. scrapely_client_python-3.1.3.dist-info/WHEEL +4 -0
  65. 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
+ ]
@@ -0,0 +1,10 @@
1
+ from ._base import HttpClient, HttpClientAsync, HttpResponse
2
+ from ._impit import ImpitHttpClient, ImpitHttpClientAsync
3
+
4
+ __all__ = [
5
+ 'HttpClient',
6
+ 'HttpClientAsync',
7
+ 'HttpResponse',
8
+ 'ImpitHttpClient',
9
+ 'ImpitHttpClientAsync',
10
+ ]