@@ -29,11 +29,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
29
29
resource_path = kwargs .pop ('resource_path' , 'logs' )
30
30
super ().__init__ (* args , resource_path = resource_path , ** kwargs )
31
31
32
- def get (self , raw : str = False ) -> str | None :
32
+ def get (self , * , raw : bool = False ) -> str | None :
33
33
"""Retrieve the log as text.
34
34
35
35
https://docs.apify.com/api/v2#/reference/logs/log/get-log
36
36
37
+ Args:
38
+ raw: If true, the log will include formating. For example, coloring character sequences.
39
+
37
40
Returns:
38
41
The retrieved log, or None, if it does not exist.
39
42
"""
@@ -51,11 +54,14 @@ def get(self, raw: str = False) -> str | None:
51
54
52
55
return None
53
56
54
- def get_as_bytes (self , raw : str = False ) -> bytes | None :
57
+ def get_as_bytes (self , * , raw : bool = False ) -> bytes | None :
55
58
"""Retrieve the log as raw bytes.
56
59
57
60
https://docs.apify.com/api/v2#/reference/logs/log/get-log
58
61
62
+ Args:
63
+ raw: If true, the log will include formating. For example, coloring character sequences.
64
+
59
65
Returns:
60
66
The retrieved log as raw bytes, or None, if it does not exist.
61
67
"""
@@ -75,11 +81,14 @@ def get_as_bytes(self, raw: str = False) -> bytes | None:
75
81
return None
76
82
77
83
@contextmanager
78
- def stream (self , raw : str = False ) -> Iterator [httpx .Response | None ]:
84
+ def stream (self , * , raw : bool = False ) -> Iterator [httpx .Response | None ]:
79
85
"""Retrieve the log as a stream.
80
86
81
87
https://docs.apify.com/api/v2#/reference/logs/log/get-log
82
88
89
+ Args:
90
+ raw: If true, the log will include formating. For example, coloring character sequences.
91
+
83
92
Returns:
84
93
The retrieved log as a context-managed streaming `Response`, or None, if it does not exist.
85
94
"""
@@ -110,11 +119,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
110
119
resource_path = kwargs .pop ('resource_path' , 'logs' )
111
120
super ().__init__ (* args , resource_path = resource_path , ** kwargs )
112
121
113
- async def get (self , raw : str = False ) -> str | None :
122
+ async def get (self , * , raw : bool = False ) -> str | None :
114
123
"""Retrieve the log as text.
115
124
116
125
https://docs.apify.com/api/v2#/reference/logs/log/get-log
117
126
127
+ Args:
128
+ raw: If true, the log will include formating. For example, coloring character sequences.
129
+
118
130
Returns:
119
131
The retrieved log, or None, if it does not exist.
120
132
"""
@@ -132,11 +144,14 @@ async def get(self, raw: str = False) -> str | None:
132
144
133
145
return None
134
146
135
- async def get_as_bytes (self , raw : str = False ) -> bytes | None :
147
+ async def get_as_bytes (self , * , raw : bool = False ) -> bytes | None :
136
148
"""Retrieve the log as raw bytes.
137
149
138
150
https://docs.apify.com/api/v2#/reference/logs/log/get-log
139
151
152
+ Args:
153
+ raw: If true, the log will include formating. For example, coloring character sequences.
154
+
140
155
Returns:
141
156
The retrieved log as raw bytes, or None, if it does not exist.
142
157
"""
@@ -156,11 +171,14 @@ async def get_as_bytes(self, raw: str = False) -> bytes | None:
156
171
return None
157
172
158
173
@asynccontextmanager
159
- async def stream (self , raw : str = False ) -> AsyncIterator [httpx .Response | None ]:
174
+ async def stream (self , * , raw : bool = False ) -> AsyncIterator [httpx .Response | None ]:
160
175
"""Retrieve the log as a stream.
161
176
162
177
https://docs.apify.com/api/v2#/reference/logs/log/get-log
163
178
179
+ Args:
180
+ raw: If true, the log will include formating. For example, coloring character sequences.
181
+
164
182
Returns:
165
183
The retrieved log as a context-managed streaming `Response`, or None, if it does not exist.
166
184
"""
@@ -175,7 +193,7 @@ async def stream(self, raw: str = False) -> AsyncIterator[httpx.Response | None]
175
193
)
176
194
177
195
yield response
178
- except Exception as exc :
196
+ except ApifyApiError as exc :
179
197
catch_not_found_or_throw (exc )
180
198
yield None
181
199
finally :
@@ -199,10 +217,14 @@ def __init__(self, log_client: LogClientAsync, to_logger: logging.Logger) -> Non
199
217
self ._streaming_task : Task | None = None
200
218
if self ._force_propagate :
201
219
to_logger .propagate = True
220
+ self ._stream_buffer = list [str ]()
221
+ # Redirected logs are forwarded to logger as soon as there are at least two split markers present in the buffer.
222
+ # For example, 2025-05-12T15:35:59.429Z
223
+ self ._split_marker = re .compile (r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)' )
202
224
203
225
def __call__ (self ) -> Task :
204
226
"""Start the streaming task. The caller has to handle any cleanup."""
205
- return asyncio .create_task (self ._stream_log (self . _to_logger ))
227
+ return asyncio .create_task (self ._stream_log ())
206
228
207
229
async def __aenter__ (self ) -> Self :
208
230
"""Start the streaming task within the context. Exiting the context will cancel the streaming task."""
@@ -222,22 +244,40 @@ async def __aexit__(
222
244
self ._streaming_task .cancel ()
223
245
self ._streaming_task = None
224
246
225
- async def _stream_log (self , to_logger : logging . Logger ) -> None :
247
+ async def _stream_log (self ) -> None :
226
248
async with self ._log_client .stream (raw = True ) as log_stream :
227
249
if not log_stream :
228
250
return
229
251
async for data in log_stream .aiter_bytes ():
230
- # Example split marker: \n2025-05-12T15:35:59.429Z
231
- date_time_marker_pattern = r'(\n\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)'
232
- splits = re .split (date_time_marker_pattern , data .decode ('utf-8' ))
233
- messages = splits [:1 ]
252
+ new_chunk = data .decode ('utf-8' )
253
+ self ._stream_buffer .append (new_chunk )
254
+ if re .findall (self ._split_marker , new_chunk ):
255
+ # If complete split marker was found in new chunk, then process the buffer.
256
+ self ._log_buffer_content (include_last_part = False )
234
257
235
- for split_marker , message_without_split_marker in zip ( splits [ 1 : - 1 : 2 ], splits [ 2 :: 2 ]):
236
- messages . append ( split_marker + message_without_split_marker )
258
+ # If the stream is finished, then the last part will be also processed.
259
+ self . _log_buffer_content ( include_last_part = True )
237
260
238
- for message in messages :
239
- to_logger .log (level = self ._guess_log_level_from_message (message ), msg = message .strip ())
240
- log_stream .close ()
261
+ def _log_buffer_content (self , * , include_last_part : bool = False ) -> None :
262
+ """Merge the whole buffer and plit it into parts based on the marker.
263
+
264
+ The last part could be incomplete, and so it can be left unprocessed and in the buffer.
265
+ """
266
+ all_parts = re .split (self ._split_marker , '' .join (self ._stream_buffer ))
267
+ # First split is empty string
268
+ if include_last_part :
269
+ message_markers = all_parts [1 ::2 ]
270
+ message_contents = all_parts [2 ::2 ]
271
+ self ._stream_buffer = []
272
+ else :
273
+ message_markers = all_parts [1 :- 2 :2 ]
274
+ message_contents = all_parts [2 :- 2 :2 ]
275
+ # The last two parts (marker and message) are possibly not complete and will be left in the buffer
276
+ self ._stream_buffer = all_parts [- 2 :]
277
+
278
+ for marker , content in zip (message_markers , message_contents ):
279
+ message = marker + content
280
+ self ._to_logger .log (level = self ._guess_log_level_from_message (message ), msg = message .strip ())
241
281
242
282
@staticmethod
243
283
def _guess_log_level_from_message (message : str ) -> int :
0 commit comments