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,29 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class ClientStatistics:
9
+ """Statistics about API client usage and rate limit errors."""
10
+
11
+ calls: int = 0
12
+ """Total number of API method calls made by the client."""
13
+
14
+ requests: int = 0
15
+ """Total number of HTTP requests sent, including retries."""
16
+
17
+ rate_limit_errors: defaultdict[int, int] = field(default_factory=lambda: defaultdict(int))
18
+ """List tracking which retry attempts encountered rate limit (429) errors."""
19
+
20
+ def add_rate_limit_error(self, attempt: int) -> None:
21
+ """Add rate limit error for specific attempt.
22
+
23
+ Args:
24
+ attempt: The attempt number (1-based indexing).
25
+ """
26
+ if attempt < 1:
27
+ raise ValueError('Attempt must be greater than 0')
28
+
29
+ self.rate_limit_errors[attempt - 1] += 1
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ import time
6
+ from asyncio import Task
7
+ from datetime import timedelta
8
+ from threading import Thread
9
+ from typing import TYPE_CHECKING, Self
10
+
11
+ from scrapely_client._docs import docs_group
12
+ from scrapely_client._utils.time import to_seconds
13
+
14
+ if TYPE_CHECKING:
15
+ import logging
16
+ from types import TracebackType
17
+
18
+ from scrapely_client._models import Run
19
+ from scrapely_client._resource_clients import RunClient, RunClientAsync
20
+
21
+
22
+ class StatusMessageWatcherBase:
23
+ """Base class for polling and logging Actor run status messages."""
24
+
25
+ _force_propagate = False
26
+ # This is final sleep time to try to get the last status and status message of finished Actor run.
27
+ # The status and status message can get set on the Actor run with a delay. Sleep time does not guarantee that the
28
+ # final message will be captured, but increases the chances of that.
29
+ _final_sleep_time_s = 6
30
+
31
+ def __init__(self, *, to_logger: logging.Logger, check_period: timedelta = timedelta(seconds=5)) -> None:
32
+ if self._force_propagate:
33
+ to_logger.propagate = True
34
+ self._to_logger = to_logger
35
+ self._check_period = to_seconds(check_period)
36
+ self._last_status_message = ''
37
+
38
+ def _log_run_data(self, run_data: Run | None) -> bool:
39
+ """Get relevant run data, log them if changed and return `True` if more data is expected.
40
+
41
+ Args:
42
+ run_data: The Run model that contains the run data.
43
+
44
+ Returns:
45
+ `True` if more data is expected, `False` otherwise.
46
+ """
47
+ if run_data is not None:
48
+ status = run_data.status or 'Unknown status'
49
+ status_message = run_data.status_message or ''
50
+ new_status_message = f'Status: {status}, Message: {status_message}'
51
+
52
+ if new_status_message != self._last_status_message:
53
+ self._last_status_message = new_status_message
54
+ self._to_logger.info(new_status_message)
55
+
56
+ return not (run_data.is_status_message_terminal or False)
57
+ return True
58
+
59
+
60
+ @docs_group('Other')
61
+ class StatusMessageWatcherAsync(StatusMessageWatcherBase):
62
+ """Polls and logs Actor run status messages in an asyncio task.
63
+
64
+ The status message and status of the Actor run are polled at a fixed interval and forwarded to the provided logger
65
+ whenever they change. There is no guarantee that every intermediate status message will be captured, especially
66
+ when messages change rapidly.
67
+
68
+ Can be used as an async context manager, which automatically starts and cancels the polling task. Alternatively,
69
+ call `start` and `stop` manually. Obtain an instance via `RunClientAsync.get_status_message_watcher`.
70
+ """
71
+
72
+ def __init__(
73
+ self, *, run_client: RunClientAsync, to_logger: logging.Logger, check_period: timedelta = timedelta(seconds=1)
74
+ ) -> None:
75
+ """Initialize `StatusMessageWatcherAsync`.
76
+
77
+ Args:
78
+ run_client: The run client used to poll the Actor run status and status message.
79
+ to_logger: The logger to which the status messages will be forwarded.
80
+ check_period: How often to poll the status message.
81
+ """
82
+ super().__init__(to_logger=to_logger, check_period=check_period)
83
+ self._run_client = run_client
84
+ self._logging_task: Task | None = None
85
+
86
+ def start(self) -> Task:
87
+ """Start the polling task.
88
+
89
+ The caller is responsible for cleanup by calling the `stop` method when done.
90
+ """
91
+ if self._logging_task and not self._logging_task.done():
92
+ raise RuntimeError('Logging task already active')
93
+ self._logging_task = asyncio.create_task(self._log_changed_status_message())
94
+ return self._logging_task
95
+
96
+ async def stop(self) -> None:
97
+ """Stop the logging task."""
98
+ if not self._logging_task:
99
+ raise RuntimeError('Logging task is not active')
100
+
101
+ self._logging_task.cancel()
102
+ try:
103
+ await self._logging_task
104
+ except asyncio.CancelledError:
105
+ pass
106
+ finally:
107
+ self._logging_task = None
108
+
109
+ async def __aenter__(self) -> Self:
110
+ """Start the logging task within the context. Exiting the context will cancel the logging task."""
111
+ self.start()
112
+ return self
113
+
114
+ async def __aexit__(
115
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
116
+ ) -> None:
117
+ """Cancel the logging task."""
118
+ if exc_type is None:
119
+ await asyncio.sleep(self._final_sleep_time_s)
120
+ await self.stop()
121
+
122
+ async def _log_changed_status_message(self) -> None:
123
+ while True:
124
+ run_data = await self._run_client.get()
125
+ if not self._log_run_data(run_data):
126
+ break
127
+ await asyncio.sleep(self._check_period)
128
+
129
+
130
+ @docs_group('Other')
131
+ class StatusMessageWatcher(StatusMessageWatcherBase):
132
+ """Polls and logs Actor run status messages in a background thread.
133
+
134
+ The status message and status of the Actor run are polled at a fixed interval and forwarded to the provided logger
135
+ whenever they change. There is no guarantee that every intermediate status message will be captured, especially
136
+ when messages change rapidly.
137
+
138
+ Can be used as a context manager, which automatically starts and stops the polling thread. Alternatively,
139
+ call `start` and `stop` manually. Obtain an instance via `RunClient.get_status_message_watcher`.
140
+ """
141
+
142
+ def __init__(
143
+ self, *, run_client: RunClient, to_logger: logging.Logger, check_period: timedelta = timedelta(seconds=1)
144
+ ) -> None:
145
+ """Initialize `StatusMessageWatcher`.
146
+
147
+ Args:
148
+ run_client: The run client used to poll the Actor run status and status message.
149
+ to_logger: The logger to which the status messages will be forwarded.
150
+ check_period: How often to poll the status message.
151
+ """
152
+ super().__init__(to_logger=to_logger, check_period=check_period)
153
+ self._run_client = run_client
154
+ self._logging_thread: Thread | None = None
155
+ self._stop_logging = False
156
+
157
+ def start(self) -> Thread:
158
+ """Start the polling thread.
159
+
160
+ The caller is responsible for cleanup by calling the `stop` method when done.
161
+ """
162
+ if self._logging_thread:
163
+ raise RuntimeError('Logging thread already active')
164
+ self._stop_logging = False
165
+ self._logging_thread = threading.Thread(target=self._log_changed_status_message)
166
+ self._logging_thread.start()
167
+ return self._logging_thread
168
+
169
+ def stop(self) -> None:
170
+ """Signal the logging thread to stop logging and wait for it to finish."""
171
+ if not self._logging_thread:
172
+ raise RuntimeError('Logging thread is not active')
173
+ self._stop_logging = True
174
+ self._logging_thread.join()
175
+ self._logging_thread = None
176
+ self._stop_logging = False
177
+
178
+ def __enter__(self) -> Self:
179
+ """Start the logging thread within the context. Exiting the context will stop the logging thread."""
180
+ self.start()
181
+ return self
182
+
183
+ def __exit__(
184
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
185
+ ) -> None:
186
+ """Stop the logging thread."""
187
+ if exc_type is None:
188
+ time.sleep(self._final_sleep_time_s)
189
+ self.stop()
190
+
191
+ def _log_changed_status_message(self) -> None:
192
+ while True:
193
+ if not self._log_run_data(self._run_client.get()):
194
+ break
195
+ if self._stop_logging:
196
+ break
197
+ time.sleep(self._check_period)
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import threading
7
+ from asyncio import Task
8
+ from datetime import UTC, datetime
9
+ from threading import Thread
10
+ from typing import TYPE_CHECKING, ClassVar, Self, cast
11
+
12
+ import impit
13
+
14
+ from scrapely_client._docs import docs_group
15
+
16
+ if TYPE_CHECKING:
17
+ from types import TracebackType
18
+
19
+ from scrapely_client._resource_clients import LogClient, LogClientAsync
20
+ from scrapely_client.types import Timeout
21
+
22
+
23
+ class StreamedLogBase:
24
+ """Base class for streaming and buffering chunked Actor run logs."""
25
+
26
+ _force_propagate = False
27
+ """Test related flag to enable propagation of logs to the `caplog` fixture during tests."""
28
+
29
+ _stream_timeout: ClassVar[Timeout] = 'no_timeout'
30
+ """Timeout for the log-stream long-poll request, which stays open for the whole Actor run.
31
+
32
+ impit applies its `timeout` to the whole request including the streamed body, so any bounded value truncates a
33
+ longer run mid-stream and raises `impit.TimeoutException` (#1040). `no_timeout` maps to impit's ~24h cap, which
34
+ is effectively unbounded for real runs and mirrors the JS client.
35
+ """
36
+
37
+ def __init__(self, to_logger: logging.Logger, *, from_start: bool = True) -> None:
38
+ if self._force_propagate:
39
+ to_logger.propagate = True
40
+ self._to_logger = to_logger
41
+ self._stream_buffer = list[bytes]()
42
+ self._split_marker = re.compile(rb'(?:\n|^)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)')
43
+ self._relevancy_time_limit: datetime | None = None if from_start else datetime.now(tz=UTC)
44
+
45
+ def _process_new_data(self, data: bytes) -> None:
46
+ new_chunk = data
47
+ self._stream_buffer.append(new_chunk)
48
+ if re.findall(self._split_marker, new_chunk):
49
+ # If complete split marker was found in new chunk, then log the buffer.
50
+ self._log_buffer_content(include_last_part=False)
51
+
52
+ def _log_buffer_content(self, *, include_last_part: bool = False) -> None:
53
+ """Merge the whole buffer and split it into parts based on the marker.
54
+
55
+ Log the messages created from the split parts and remove them from buffer.
56
+ The last part could be incomplete, and so it can be left unprocessed in the buffer until later.
57
+ """
58
+ all_parts = re.split(self._split_marker, b''.join(self._stream_buffer))[1:] # The First split is empty
59
+ if include_last_part:
60
+ message_markers = all_parts[0::2]
61
+ message_contents = all_parts[1::2]
62
+ self._stream_buffer = []
63
+ else:
64
+ message_markers = all_parts[0:-2:2]
65
+ message_contents = all_parts[1:-2:2]
66
+ # The last two parts (marker and message) are possibly not complete and will be left in the buffer
67
+ self._stream_buffer = all_parts[-2:]
68
+
69
+ for marker, content in zip(message_markers, message_contents, strict=False):
70
+ decoded_marker = marker.decode('utf-8')
71
+ decoded_content = content.decode('utf-8')
72
+ if self._relevancy_time_limit:
73
+ log_time = datetime.fromisoformat(decoded_marker)
74
+ if log_time < self._relevancy_time_limit:
75
+ # Skip irrelevant logs
76
+ continue
77
+ message = decoded_marker + decoded_content
78
+ self._to_logger.log(level=self._guess_log_level_from_message(message), msg=message.strip())
79
+
80
+ @staticmethod
81
+ def _guess_log_level_from_message(message: str) -> int:
82
+ """Guess the log level from the message."""
83
+ # Using only levels explicitly mentioned in the logging module
84
+ known_levels = ('CRITICAL', 'FATAL', 'ERROR', 'WARN', 'WARNING', 'INFO', 'DEBUG', 'NOTSET')
85
+ for level in known_levels:
86
+ if level in message:
87
+ # `getLevelName` returns an `int` when string is passed as input.
88
+ return cast('int', logging.getLevelName(level))
89
+ # Unknown log level. Fall back to the default.
90
+ return logging.INFO
91
+
92
+
93
+ @docs_group('Other')
94
+ class StreamedLog(StreamedLogBase):
95
+ """Streams Actor run log output to a Python logger in a background thread.
96
+
97
+ The log stream is consumed in a background thread and each log message is forwarded to the provided logger with
98
+ an appropriate log level inferred from the message content.
99
+
100
+ Can be used as a context manager, which automatically starts and stops the streaming thread. Alternatively,
101
+ call `start` and `stop` manually. Obtain an instance via `RunClient.get_streamed_log`.
102
+ """
103
+
104
+ def __init__(self, log_client: LogClient, *, to_logger: logging.Logger, from_start: bool = True) -> None:
105
+ """Initialize `StreamedLog`.
106
+
107
+ Args:
108
+ log_client: The log client used to stream raw log data from the Actor run.
109
+ to_logger: The logger to which the log messages will be forwarded.
110
+ from_start: If `True`, all logs from the start of the Actor run will be streamed. If `False`, only newly
111
+ arrived logs will be streamed. This can be useful for long-running Actors in stand-by mode where only
112
+ recent logs are relevant.
113
+ """
114
+ super().__init__(to_logger=to_logger, from_start=from_start)
115
+ self._log_client = log_client
116
+ self._streaming_thread: Thread | None = None
117
+ self._stop_logging = False
118
+
119
+ def start(self) -> Thread:
120
+ """Start the streaming thread.
121
+
122
+ The caller is responsible for cleanup by calling the `stop` method when done.
123
+ """
124
+ if self._streaming_thread:
125
+ raise RuntimeError('Streaming thread already active')
126
+ self._stop_logging = False
127
+ # A daemon thread so a stream still blocked on a read can never hold up interpreter shutdown.
128
+ self._streaming_thread = threading.Thread(target=self._stream_log, daemon=True)
129
+ self._streaming_thread.start()
130
+ return self._streaming_thread
131
+
132
+ def stop(self) -> None:
133
+ """Signal the streaming thread to stop logging and wait for it to finish."""
134
+ if not self._streaming_thread:
135
+ raise RuntimeError('Streaming thread is not active')
136
+ self._stop_logging = True
137
+ self._streaming_thread.join()
138
+ self._streaming_thread = None
139
+ self._stop_logging = False
140
+
141
+ def __enter__(self) -> Self:
142
+ """Start the streaming thread within the context. Exiting the context will finish the streaming thread."""
143
+ self.start()
144
+ return self
145
+
146
+ def __exit__(
147
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
148
+ ) -> None:
149
+ """Stop the streaming thread."""
150
+ self.stop()
151
+
152
+ def _stream_log(self) -> None:
153
+ try:
154
+ with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
155
+ if not log_stream:
156
+ return
157
+ try:
158
+ for data in log_stream.iter_bytes():
159
+ self._process_new_data(data)
160
+ if self._stop_logging:
161
+ break
162
+ finally:
163
+ # Flush the last buffered part even if the read timed out or was stopped.
164
+ self._log_buffer_content(include_last_part=True)
165
+ except impit.TimeoutException:
166
+ # With `no_timeout` this fires only if the run outlives impit's ~24h cap or the connection stalls.
167
+ # The stream cannot continue, so warn and let the thread end instead of leaking a traceback (#1040).
168
+ self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
169
+ except Exception:
170
+ # Any other failure in log redirection must not escape the background thread; log it instead.
171
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
172
+
173
+
174
+ @docs_group('Other')
175
+ class StreamedLogAsync(StreamedLogBase):
176
+ """Streams Actor run log output to a Python logger in an asyncio task.
177
+
178
+ The log stream is consumed in a background asyncio task and each log message is forwarded to the provided logger
179
+ with an appropriate log level inferred from the message content.
180
+
181
+ Can be used as an async context manager, which automatically starts and cancels the streaming task. Alternatively,
182
+ call `start` and `stop` manually. Obtain an instance via `RunClientAsync.get_streamed_log`.
183
+ """
184
+
185
+ def __init__(self, log_client: LogClientAsync, *, to_logger: logging.Logger, from_start: bool = True) -> None:
186
+ """Initialize `StreamedLogAsync`.
187
+
188
+ Args:
189
+ log_client: The async log client used to stream raw log data from the Actor run.
190
+ to_logger: The logger to which the log messages will be forwarded.
191
+ from_start: If `True`, all logs from the start of the Actor run will be streamed. If `False`, only newly
192
+ arrived logs will be streamed. This can be useful for long-running Actors in stand-by mode where only
193
+ recent logs are relevant.
194
+ """
195
+ super().__init__(to_logger=to_logger, from_start=from_start)
196
+ self._log_client = log_client
197
+ self._streaming_task: Task | None = None
198
+
199
+ def start(self) -> Task:
200
+ """Start the streaming task.
201
+
202
+ The caller is responsible for cleanup by calling the `stop` method when done.
203
+ """
204
+ if self._streaming_task and not self._streaming_task.done():
205
+ raise RuntimeError('Streaming task already active')
206
+ self._streaming_task = asyncio.create_task(self._stream_log())
207
+ return self._streaming_task
208
+
209
+ async def stop(self) -> None:
210
+ """Stop the streaming task."""
211
+ if not self._streaming_task:
212
+ raise RuntimeError('Streaming task is not active')
213
+
214
+ self._streaming_task.cancel()
215
+ try:
216
+ await self._streaming_task
217
+ except asyncio.CancelledError:
218
+ pass
219
+ finally:
220
+ self._streaming_task = None
221
+
222
+ async def __aenter__(self) -> Self:
223
+ """Start the streaming task within the context. Exiting the context will cancel the streaming task."""
224
+ self.start()
225
+ return self
226
+
227
+ async def __aexit__(
228
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
229
+ ) -> None:
230
+ """Cancel the streaming task."""
231
+ await self.stop()
232
+
233
+ async def _stream_log(self) -> None:
234
+ try:
235
+ async with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
236
+ if not log_stream:
237
+ return
238
+ try:
239
+ async for data in log_stream.aiter_bytes():
240
+ self._process_new_data(data)
241
+ finally:
242
+ # Flush the last buffered part even if the task is cancelled by `stop()`.
243
+ self._log_buffer_content(include_last_part=True)
244
+ except impit.TimeoutException:
245
+ # As in `StreamedLog._stream_log`, impit's whole-request timeout on the long-lived stream is an
246
+ # expected terminal condition, not an error, so log a warning and end the task instead of a traceback.
247
+ self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
248
+ except Exception:
249
+ # Exception in log redirection should not propagate further.
250
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')