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,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:')
|