apify-client 3.3.0__tar.gz → 3.3.1b1__tar.gz

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.
Files changed (70) hide show
  1. {apify_client-3.3.0 → apify_client-3.3.1b1}/CHANGELOG.md +9 -0
  2. {apify_client-3.3.0 → apify_client-3.3.1b1}/PKG-INFO +1 -1
  3. {apify_client-3.3.0 → apify_client-3.3.1b1}/pyproject.toml +1 -1
  4. {apify_client-3.3.0 → apify_client-3.3.1b1}/pyproject.toml.orig +1 -1
  5. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_streamed_log.py +105 -43
  6. {apify_client-3.3.0 → apify_client-3.3.1b1}/CONTRIBUTING.md +0 -0
  7. {apify_client-3.3.0 → apify_client-3.3.1b1}/LICENSE +0 -0
  8. {apify_client-3.3.0 → apify_client-3.3.1b1}/README.md +0 -0
  9. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/__init__.py +0 -0
  10. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_apify_client.py +0 -0
  11. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_client_registry.py +0 -0
  12. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_consts.py +0 -0
  13. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_docs.py +0 -0
  14. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_literals.py +0 -0
  15. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_logging.py +0 -0
  16. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_models.py +0 -0
  17. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_pagination.py +0 -0
  18. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/__init__.py +0 -0
  19. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/_resource_client.py +0 -0
  20. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/actor.py +0 -0
  21. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/actor_collection.py +0 -0
  22. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/actor_env_var.py +0 -0
  23. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/actor_env_var_collection.py +0 -0
  24. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/actor_version.py +0 -0
  25. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/actor_version_collection.py +0 -0
  26. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/build.py +0 -0
  27. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/build_collection.py +0 -0
  28. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/dataset.py +0 -0
  29. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/dataset_collection.py +0 -0
  30. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/key_value_store.py +0 -0
  31. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/key_value_store_collection.py +0 -0
  32. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/log.py +0 -0
  33. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/request_queue.py +0 -0
  34. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/request_queue_collection.py +0 -0
  35. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/run.py +0 -0
  36. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/run_collection.py +0 -0
  37. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/schedule.py +0 -0
  38. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/schedule_collection.py +0 -0
  39. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/store_collection.py +0 -0
  40. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/task.py +0 -0
  41. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/task_collection.py +0 -0
  42. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/user.py +0 -0
  43. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/webhook.py +0 -0
  44. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/webhook_collection.py +0 -0
  45. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/webhook_dispatch.py +0 -0
  46. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_resource_clients/webhook_dispatch_collection.py +0 -0
  47. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_statistics.py +0 -0
  48. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_status_message_watcher.py +0 -0
  49. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_typeddicts.py +0 -0
  50. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/__init__.py +0 -0
  51. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/crypto.py +0 -0
  52. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/encoding.py +0 -0
  53. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/errors.py +0 -0
  54. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/http.py +0 -0
  55. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/time.py +0 -0
  56. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/try_import.py +0 -0
  57. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/_utils/wait_for_resources.py +0 -0
  58. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/errors.py +0 -0
  59. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_clients/__init__.py +0 -0
  60. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_clients/_base.py +0 -0
  61. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_clients/_httpx2.py +0 -0
  62. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_clients/_impit.py +0 -0
  63. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_clients/_streamed_body.py +0 -0
  64. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_compressors/__init__.py +0 -0
  65. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_compressors/_base.py +0 -0
  66. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_compressors/_brotli.py +0 -0
  67. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_compressors/_gzip.py +0 -0
  68. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/http_compressors/_resolve.py +0 -0
  69. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/py.typed +0 -0
  70. {apify_client-3.3.0 → apify_client-3.3.1b1}/src/apify_client/types.py +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ <!-- git-cliff-unreleased-start -->
6
+ ## 3.3.1 - **not yet released**
7
+
8
+ ### 🐛 Bug Fixes
9
+
10
+ - Redirect the run log when the first log stream comes back empty ([#1084](https://github.com/apify/apify-client-python/pull/1084)) ([a370e7a](https://github.com/apify/apify-client-python/commit/a370e7a9db75053c2f65ad626ba1bcb67c4cf398)) by [@vdusek](https://github.com/vdusek)
11
+
12
+
13
+ <!-- git-cliff-unreleased-end -->
5
14
  ## [3.3.0](https://github.com/apify/apify-client-python/releases/tag/v3.3.0) (2026-10-08)
6
15
 
7
16
  ### 🚀 Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apify_client
3
- Version: 3.3.0
3
+ Version: 3.3.1b1
4
4
  Summary: Apify API client for Python
5
5
  Keywords: apify,api,client,automation,crawling,scraping
6
6
  Author: Apify Technologies s.r.o.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify_client"
7
- version = "3.3.0"
7
+ version = "3.3.1b1"
8
8
  description = "Apify API client for Python"
9
9
  license = "Apache-2.0"
10
10
  license-files = ["LICENSE"]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify_client"
7
- version = "3.3.0"
7
+ version = "3.3.1b1"
8
8
  description = "Apify API client for Python"
9
9
  authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
10
10
  license = "Apache-2.0"
@@ -32,6 +32,17 @@ class StreamedLogBase:
32
32
  duration of the run (Impit currently maps it to an effective 24-hour cap) and mirrors the JS client.
33
33
  """
34
34
 
35
+ _empty_stream_retry_s: ClassVar[float] = 0.5
36
+ """Pause before the first reopen of a log stream that ended before the run logged anything.
37
+
38
+ The API serves the log of a run that has not logged anything yet as an empty stream that ends at once. Each further
39
+ reopen doubles the pause up to `_empty_stream_max_retry_s`, which bounds the request rate while a run waits long to
40
+ start, for example for free memory.
41
+ """
42
+
43
+ _empty_stream_max_retry_s: ClassVar[float] = 5
44
+ """Upper bound on the pause between reopens of an empty log stream."""
45
+
35
46
  def __init__(self, to_logger: logging.Logger, *, from_start: bool = True) -> None:
36
47
  if self._force_propagate:
37
48
  to_logger.propagate = True
@@ -39,8 +50,11 @@ class StreamedLogBase:
39
50
  self._stream_buffer = list[bytes]()
40
51
  self._split_marker = re.compile(rb'(?:\n|^)(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)')
41
52
  self._relevancy_time_limit: datetime | None = None if from_start else datetime.now(tz=UTC)
53
+ self._received_data = False
42
54
 
43
55
  def _process_new_data(self, data: bytes) -> None:
56
+ if data:
57
+ self._received_data = True
44
58
  new_chunk = data
45
59
  self._stream_buffer.append(new_chunk)
46
60
  if re.findall(self._split_marker, new_chunk):
@@ -75,6 +89,12 @@ class StreamedLogBase:
75
89
  message = decoded_marker + decoded_content
76
90
  self._to_logger.log(level=self._guess_log_level_from_message(message), msg=message.strip())
77
91
 
92
+ def _process_whole_log(self, log: bytes | None) -> None:
93
+ """Redirect a whole log read in one request, including its last part."""
94
+ if log:
95
+ self._process_new_data(log)
96
+ self._log_buffer_content(include_last_part=True)
97
+
78
98
  @staticmethod
79
99
  def _guess_log_level_from_message(message: str) -> int:
80
100
  """Guess the log level from the message."""
@@ -120,7 +140,7 @@ class StreamedLog(StreamedLogBase):
120
140
  self._log_client = log_client
121
141
  self._streaming_thread: Thread | None = None
122
142
  self._log_stream: HttpResponse | None = None
123
- self._stop_logging = False
143
+ self._stop_event = threading.Event()
124
144
 
125
145
  def start(self) -> Thread:
126
146
  """Start the streaming thread.
@@ -129,7 +149,8 @@ class StreamedLog(StreamedLogBase):
129
149
  """
130
150
  if self._streaming_thread and self._streaming_thread.is_alive():
131
151
  raise RuntimeError('Streaming thread already active')
132
- self._stop_logging = False
152
+ self._stop_event.clear()
153
+ self._received_data = False
133
154
  # A daemon thread so a stream still blocked on a read can never hold up interpreter shutdown.
134
155
  self._streaming_thread = threading.Thread(target=self._stream_log, daemon=True)
135
156
  self._streaming_thread.start()
@@ -138,13 +159,14 @@ class StreamedLog(StreamedLogBase):
138
159
  def stop(self) -> None:
139
160
  """Signal the streaming thread to stop logging and wait up to `_stop_timeout_s` for it to finish.
140
161
 
141
- A thread that outlives the wait is a daemon with `_stop_logging` set, so it exits after at most one more chunk,
162
+ A thread that outlives the wait is a daemon with `_stop_event` set, so it exits after at most one more chunk,
142
163
  and only then does its buffered tail reach the logger. Its handle is kept while it is alive, so `start` cannot
143
- revive it beside a second thread on the same buffer.
164
+ revive it beside a second thread on the same buffer. If no stream has delivered anything yet, the thread reads
165
+ the whole log in one request before it ends.
144
166
  """
145
167
  if not self._streaming_thread:
146
168
  raise RuntimeError('Streaming thread is not active')
147
- self._stop_logging = True
169
+ self._stop_event.set()
148
170
  # Read once; the streaming thread clears the attribute as soon as the stream ends.
149
171
  log_stream = self._log_stream
150
172
  if log_stream is not None:
@@ -173,39 +195,60 @@ class StreamedLog(StreamedLogBase):
173
195
 
174
196
  def _stream_log(self) -> None:
175
197
  try:
176
- with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
177
- if not log_stream:
198
+ # An empty stream means the run has not logged anything yet, so reopen it until the first bytes arrive.
199
+ retry_s = self._empty_stream_retry_s
200
+ while not self._stop_event.is_set():
201
+ if not self._stream_log_once() or self._received_data:
178
202
  return
179
- # Published so `stop` can close the response.
180
- self._log_stream = log_stream
181
- try:
182
- # `stop` may have run before the response existed for it to close.
183
- if self._stop_logging:
184
- return
185
- for data in log_stream.iter_bytes():
186
- self._process_new_data(data)
187
- if self._stop_logging:
188
- break
189
- finally:
190
- self._log_stream = None
191
- try:
192
- # Flush the last buffered part even if the read timed out or was stopped.
193
- self._log_buffer_content(include_last_part=True)
194
- except Exception:
195
- # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop
196
- # is in progress.
197
- self._to_logger.exception('Log redirection stopped due to unexpected error:')
203
+ self._stop_event.wait(retry_s)
204
+ retry_s = min(retry_s * 2, self._empty_stream_max_retry_s)
198
205
  except Exception as exc:
199
- if self._stop_logging:
206
+ if self._stop_event.is_set():
200
207
  # `stop` closed the stream out from under the read, so the failure is expected.
201
208
  self._to_logger.debug('Log streaming stopped while `stop` was in progress: %r', exc)
202
- return
203
- if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
209
+ elif self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
204
210
  # The stream cannot continue, so warn and let the thread end instead of leaking a traceback.
205
211
  self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
212
+ return
206
213
  else:
207
214
  # Any other failure in log redirection must not escape the background thread; log it instead.
208
215
  self._to_logger.exception('Log redirection stopped due to unexpected error:')
216
+ return
217
+ if self._received_data:
218
+ return
219
+ # Stopped before any stream delivered a byte, which a run that finishes quickly can cause.
220
+ try:
221
+ self._process_whole_log(self._log_client.get_as_bytes(raw=True))
222
+ except Exception:
223
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
224
+
225
+ def _stream_log_once(self) -> bool:
226
+ """Redirect one log stream until it ends or `stop` is called. Return `False` when the log does not exist."""
227
+ with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
228
+ if not log_stream:
229
+ return False
230
+ # Published so `stop` can close the response.
231
+ self._log_stream = log_stream
232
+ try:
233
+ # `stop` may have run before the response existed for it to close. No earlier stream delivered
234
+ # anything, and one opened this late would end after its first chunk, so read the whole log in one
235
+ # request.
236
+ if self._stop_event.is_set():
237
+ return True
238
+ for data in log_stream.iter_bytes():
239
+ self._process_new_data(data)
240
+ if self._stop_event.is_set():
241
+ break
242
+ finally:
243
+ self._log_stream = None
244
+ try:
245
+ # Flush the last buffered part even if the read timed out or was stopped.
246
+ self._log_buffer_content(include_last_part=True)
247
+ except Exception:
248
+ # A truncated stream leaves an undecodable tail, which is worth a traceback even while a stop is
249
+ # in progress.
250
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
251
+ return True
209
252
 
210
253
 
211
254
  @docs_group('Other')
@@ -240,14 +283,19 @@ class StreamedLogAsync(StreamedLogBase):
240
283
  """
241
284
  if self._streaming_task and not self._streaming_task.done():
242
285
  raise RuntimeError('Streaming task already active')
286
+ self._received_data = False
243
287
  self._streaming_task = asyncio.create_task(self._stream_log())
244
288
  return self._streaming_task
245
289
 
246
290
  async def stop(self) -> None:
247
- """Stop the streaming task."""
291
+ """Stop the streaming task.
292
+
293
+ If no stream has delivered anything yet, read the whole log in one request instead.
294
+ """
248
295
  if not self._streaming_task:
249
296
  raise RuntimeError('Streaming task is not active')
250
297
 
298
+ was_streaming = not self._streaming_task.done()
251
299
  self._streaming_task.cancel()
252
300
  try:
253
301
  await self._streaming_task
@@ -255,6 +303,13 @@ class StreamedLogAsync(StreamedLogBase):
255
303
  pass
256
304
  finally:
257
305
  self._streaming_task = None
306
+ if not was_streaming or self._received_data:
307
+ return
308
+ # Stopped before any stream delivered a byte, which a run that finishes quickly can cause.
309
+ try:
310
+ self._process_whole_log(await self._log_client.get_as_bytes(raw=True))
311
+ except Exception:
312
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
258
313
 
259
314
  async def __aenter__(self) -> Self:
260
315
  """Start the streaming task within the context. Exiting the context will cancel the streaming task."""
@@ -269,20 +324,27 @@ class StreamedLogAsync(StreamedLogBase):
269
324
 
270
325
  async def _stream_log(self) -> None:
271
326
  try:
272
- async with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
273
- if not log_stream:
274
- return
275
- try:
276
- async for data in log_stream.aiter_bytes():
277
- self._process_new_data(data)
278
- finally:
327
+ # An empty stream means the run has not logged anything yet, so reopen it until the first bytes arrive.
328
+ retry_s = self._empty_stream_retry_s
329
+ while True:
330
+ async with self._log_client.stream(raw=True, timeout=self._stream_timeout) as log_stream:
331
+ if not log_stream:
332
+ return
279
333
  try:
280
- # Flush the last buffered part even if the task is cancelled by `stop()`.
281
- self._log_buffer_content(include_last_part=True)
282
- except Exception:
283
- # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
284
- # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
285
- self._to_logger.exception('Log redirection stopped due to unexpected error:')
334
+ async for data in log_stream.aiter_bytes():
335
+ self._process_new_data(data)
336
+ finally:
337
+ try:
338
+ # Flush the last buffered part even if the task is cancelled by `stop()`.
339
+ self._log_buffer_content(include_last_part=True)
340
+ except Exception:
341
+ # A truncated stream leaves an undecodable tail. Keeping the failure here also keeps the
342
+ # cancellation `stop` raised propagating, so the task ends up cancelled as asyncio expects.
343
+ self._to_logger.exception('Log redirection stopped due to unexpected error:')
344
+ if self._received_data:
345
+ return
346
+ await asyncio.sleep(retry_s)
347
+ retry_s = min(retry_s * 2, self._empty_stream_max_retry_s)
286
348
  except Exception as exc:
287
349
  if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
288
350
  # A timeout on the long-lived stream is an expected terminal condition, not an error.
File without changes
File without changes