apify 4.0.1b4__tar.gz → 4.0.2__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 (71) hide show
  1. {apify-4.0.1b4 → apify-4.0.2}/CHANGELOG.md +14 -4
  2. {apify-4.0.1b4 → apify-4.0.2}/PKG-INFO +1 -1
  3. {apify-4.0.1b4 → apify-4.0.2}/pyproject.toml +2 -2
  4. {apify-4.0.1b4 → apify-4.0.2}/pyproject.toml.orig +2 -2
  5. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_actor.py +4 -6
  6. {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/_apify_event_manager.py +66 -28
  7. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_async_thread.py +64 -0
  8. apify-4.0.2/src/apify/scrapy/_warnings.py +22 -0
  9. apify-4.0.2/src/apify/scrapy/extensions/__init__.py +4 -0
  10. apify-4.0.2/src/apify/scrapy/extensions/_graceful_stop.py +55 -0
  11. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/requests.py +56 -20
  12. apify-4.0.2/src/apify/scrapy/scheduler.py +498 -0
  13. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/utils.py +3 -0
  14. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_dataset_client.py +3 -2
  15. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_request_queue_shared_client.py +187 -19
  16. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_request_queue_single_client.py +8 -2
  17. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/_dataset_client.py +3 -2
  18. apify-4.0.1b4/src/apify/scrapy/extensions/__init__.py +0 -3
  19. apify-4.0.1b4/src/apify/scrapy/scheduler.py +0 -203
  20. {apify-4.0.1b4 → apify-4.0.2}/CONTRIBUTING.md +0 -0
  21. {apify-4.0.1b4 → apify-4.0.2}/LICENSE +0 -0
  22. {apify-4.0.1b4 → apify-4.0.2}/README.md +0 -0
  23. {apify-4.0.1b4 → apify-4.0.2}/src/apify/__init__.py +0 -0
  24. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_charging.py +0 -0
  25. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_configuration.py +0 -0
  26. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_consts.py +0 -0
  27. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_crypto.py +0 -0
  28. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_proxy_configuration.py +0 -0
  29. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_utils.py +0 -0
  30. {apify-4.0.1b4 → apify-4.0.2}/src/apify/_webhook.py +0 -0
  31. {apify-4.0.1b4 → apify-4.0.2}/src/apify/errors.py +0 -0
  32. {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/__init__.py +0 -0
  33. {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/_types.py +0 -0
  34. {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/py.typed +0 -0
  35. {apify-4.0.1b4 → apify-4.0.2}/src/apify/log.py +0 -0
  36. {apify-4.0.1b4 → apify-4.0.2}/src/apify/py.typed +0 -0
  37. {apify-4.0.1b4 → apify-4.0.2}/src/apify/request_loaders/__init__.py +0 -0
  38. {apify-4.0.1b4 → apify-4.0.2}/src/apify/request_loaders/_apify_request_list.py +0 -0
  39. {apify-4.0.1b4 → apify-4.0.2}/src/apify/request_loaders/py.typed +0 -0
  40. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/__init__.py +0 -0
  41. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_actor_runner.py +0 -0
  42. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_detection.py +0 -0
  43. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_logging_config.py +0 -0
  44. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_serialization.py +0 -0
  45. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/extensions/_httpcache.py +0 -0
  46. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/middlewares/__init__.py +0 -0
  47. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/middlewares/apify_proxy.py +0 -0
  48. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/middlewares/py.typed +0 -0
  49. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/pipelines/__init__.py +0 -0
  50. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/pipelines/actor_dataset_push.py +0 -0
  51. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/pipelines/py.typed +0 -0
  52. {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/py.typed +0 -0
  53. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/__init__.py +0 -0
  54. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/__init__.py +0 -0
  55. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_alias_resolving.py +0 -0
  56. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_api_client_creation.py +0 -0
  57. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_key_value_store_client.py +0 -0
  58. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_models.py +0 -0
  59. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_request_queue_client.py +0 -0
  60. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_storage_client.py +0 -0
  61. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_utils.py +0 -0
  62. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/py.typed +0 -0
  63. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/__init__.py +0 -0
  64. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/_key_value_store_client.py +0 -0
  65. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/_storage_client.py +0 -0
  66. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_ppe_dataset_mixin.py +0 -0
  67. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_smart_apify/__init__.py +0 -0
  68. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_smart_apify/_storage_client.py +0 -0
  69. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/py.typed +0 -0
  70. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storages/__init__.py +0 -0
  71. {apify-4.0.1b4 → apify-4.0.2}/src/apify/storages/py.typed +0 -0
@@ -2,13 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- <!-- git-cliff-unreleased-start -->
6
- ## 4.0.1 - **not yet released**
5
+ ## [4.0.2](https://github.com/apify/apify-sdk-python/releases/tag/v4.0.2) (2026-09-03)
6
+
7
+ ### 🐛 Bug Fixes
8
+
9
+ - Prolong and track shared request queue locks to prevent duplicate processing ([#1062](https://github.com/apify/apify-sdk-python/pull/1062)) ([ccaad7b](https://github.com/apify/apify-sdk-python/commit/ccaad7b71cd6cd1ddc2c71e5e9d9780001f2688a)) by [@vdusek](https://github.com/vdusek)
10
+ - Verify shared request queue is_finished against per-request reads ([#1087](https://github.com/apify/apify-sdk-python/pull/1087)) ([22613aa](https://github.com/apify/apify-sdk-python/commit/22613aac0ce4e6ef852d5d3daf8c1a25cb3731e3)) by [@vdusek](https://github.com/vdusek), closes [#808](https://github.com/apify/apify-sdk-python/issues/808)
11
+ - Keep non-forefront reclaimed requests pending in the single request queue client ([#1060](https://github.com/apify/apify-sdk-python/pull/1060)) ([b61890c](https://github.com/apify/apify-sdk-python/commit/b61890cf286c3a83faedac69315acff74b5fba5b)) by [@vdusek](https://github.com/vdusek)
12
+ - **scrapy:** Stop silently dropping in-flight requests and redirects ([#1097](https://github.com/apify/apify-sdk-python/pull/1097)) ([5a0a752](https://github.com/apify/apify-sdk-python/commit/5a0a752523b95c3143e5058fe07785e69817f4a2)) by [@vdusek](https://github.com/vdusek)
13
+ - **scrapy:** Stop the crawl gracefully when the Actor run is migrated or aborted ([#1104](https://github.com/apify/apify-sdk-python/pull/1104)) ([d560af9](https://github.com/apify/apify-sdk-python/commit/d560af953f384cefd5a9c30e55a26dc3a9a49498)) by [@vdusek](https://github.com/vdusek), closes [#1103](https://github.com/apify/apify-sdk-python/issues/1103)
14
+
15
+
16
+ ## [4.0.1](https://github.com/apify/apify-sdk-python/releases/tag/v4.0.1) (2026-08-07)
7
17
 
8
18
  ### 🐛 Bug Fixes
9
19
 
10
20
  - Close the platform events websocket iterator on shutdown ([#1077](https://github.com/apify/apify-sdk-python/pull/1077)) ([cad8d7e](https://github.com/apify/apify-sdk-python/commit/cad8d7e9e95e91270348c6b9b2d57b2a7d98e733)) by [@vdusek](https://github.com/vdusek)
11
21
  - Do not abort Actor exit when the terminal status message fails ([#1078](https://github.com/apify/apify-sdk-python/pull/1078)) ([439ee7b](https://github.com/apify/apify-sdk-python/commit/439ee7b68fe71bc0965f3ed3f8b08d7666b6f657)) by [@vdusek](https://github.com/vdusek)
22
+ - **events:** Open the platform websocket only once per context ([#1082](https://github.com/apify/apify-sdk-python/pull/1082)) ([c471dde](https://github.com/apify/apify-sdk-python/commit/c471dde0cd4d07cbf5ff3eb91347701da4d0fae5)) by [@vdusek](https://github.com/vdusek)
12
23
 
13
24
  ### ⚡ Performance
14
25
 
@@ -16,7 +27,6 @@ All notable changes to this project will be documented in this file.
16
27
  - Allow concurrent dataset pushes by narrowing the push-path locks ([#1079](https://github.com/apify/apify-sdk-python/pull/1079)) ([7d23ecd](https://github.com/apify/apify-sdk-python/commit/7d23ecdc656f36cc087a23e419ae77fde1adbad8)) by [@vdusek](https://github.com/vdusek)
17
28
 
18
29
 
19
- <!-- git-cliff-unreleased-end -->
20
30
  ## [4.0.0](https://github.com/apify/apify-sdk-python/releases/tag/v4.0.0) (2026-07-20)
21
31
 
22
32
  ### 🚀 Features
@@ -647,4 +657,4 @@ All notable changes to this project will be documented in this file.
647
657
  - Key error for storage name ([#28](https://github.com/apify/apify-sdk-python/pull/28)) ([83b30a9](https://github.com/apify/apify-sdk-python/commit/83b30a90df4d3b173302f1c6006b346091fced60)) by [@drobnikj](https://github.com/drobnikj)
648
658
 
649
659
 
650
- <!-- generated by git-cliff -->
660
+ <!-- generated by git-cliff -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apify
3
- Version: 4.0.1b4
3
+ Version: 4.0.2
4
4
  Summary: Apify SDK for Python
5
5
  Keywords: apify,sdk,automation,chrome,crawlee,crawler,headless,scraper,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"
7
- version = "4.0.1b4"
7
+ version = "4.0.2"
8
8
  description = "Apify SDK for Python"
9
9
  license = "Apache-2.0"
10
10
  license-files = ["LICENSE"]
@@ -189,7 +189,7 @@ quote-style = "single"
189
189
  indent-style = "space"
190
190
 
191
191
  [tool.pytest.ini_options]
192
- addopts = "-r a --verbose"
192
+ addopts = "-r a --verbose --dist worksteal"
193
193
  asyncio_default_fixture_loop_scope = "function"
194
194
  asyncio_mode = "auto"
195
195
  timeout = 1800
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "apify"
7
- version = "4.0.1b4"
7
+ version = "4.0.2"
8
8
  description = "Apify SDK for Python"
9
9
  authors = [{ name = "Apify Technologies s.r.o.", email = "support@apify.com" }]
10
10
  license = "Apache-2.0"
@@ -198,7 +198,7 @@ known-first-party = ["apify_client", "crawlee"]
198
198
  max-branches = 18
199
199
 
200
200
  [tool.pytest.ini_options]
201
- addopts = "-r a --verbose"
201
+ addopts = "-r a --verbose --dist worksteal"
202
202
  asyncio_default_fixture_loop_scope = "function"
203
203
  asyncio_mode = "auto"
204
204
  timeout = 1800
@@ -1249,12 +1249,10 @@ class _ActorType:
1249
1249
  # the reboot. Typically, crawlers are listening for the MIGRATING event to stop processing new requests.
1250
1250
  # We can't just emit the events and wait for all listeners to finish,
1251
1251
  # because this method might be called from an event listener itself, and we would deadlock.
1252
- persist_state_listeners = flatten(
1253
- (self.event_manager._listeners_to_wrappers[Event.PERSIST_STATE] or {}).values() # noqa: SLF001
1254
- )
1255
- migrating_listeners = flatten(
1256
- (self.event_manager._listeners_to_wrappers[Event.MIGRATING] or {}).values() # noqa: SLF001
1257
- )
1252
+ # Read the mapping with `get` - subscripting it would insert entries for events nobody listens to.
1253
+ listeners_to_wrappers = self.event_manager._listeners_to_wrappers # noqa: SLF001
1254
+ persist_state_listeners = flatten(listeners_to_wrappers.get(Event.PERSIST_STATE, {}).values())
1255
+ migrating_listeners = flatten(listeners_to_wrappers.get(Event.MIGRATING, {}).values())
1258
1256
 
1259
1257
  async def safe_dispatch(listener: Any, data: Any) -> None:
1260
1258
  try:
@@ -86,29 +86,47 @@ class ApifyEventManager(EventManager):
86
86
  self._platform_events_websocket: websockets.asyncio.client.ClientConnection | None = None
87
87
  """WebSocket connection to the platform events."""
88
88
 
89
- self._process_platform_messages_task: asyncio.Task | None = None
89
+ self._process_platform_messages_task: asyncio.Task[None] | None = None
90
90
  """Task for processing messages from the platform websocket."""
91
91
 
92
- self._connected_to_platform_websocket: asyncio.Future[bool] | None = None
93
- """Future that resolves when the connection to the platform websocket is established."""
92
+ self._connected_to_platform_websocket: asyncio.Future[None] | None = None
93
+ """Resolves once the platform websocket is connected, or fails with the error that prevented the first
94
+ connection, so that `__aenter__` can report it.
95
+ """
94
96
 
95
97
  @override
96
98
  async def __aenter__(self) -> Self:
99
+ """Initialize the event manager upon entering the async context.
100
+
101
+ On the outermost entry, it connects to the platform events websocket and starts consuming its messages.
102
+ """
97
103
  await super().__aenter__()
98
- self._connected_to_platform_websocket = asyncio.Future()
99
104
 
100
- # Run tasks but don't await them
101
- if self._configuration.actor_events_ws_url:
102
- self._process_platform_messages_task = asyncio.create_task(
103
- self._process_platform_messages(self._configuration.actor_events_ws_url)
104
- )
105
- is_connected = await self._connected_to_platform_websocket
106
- if not is_connected:
107
- # Exit the already-entered parent so the recurring persist state task does not leak.
108
- await self.__aexit__(None, None, None)
109
- raise RuntimeError('Error connecting to platform events websocket!')
110
- else:
105
+ # Only the outermost context owns the websocket, so a nested one must not open a second connection.
106
+ if self._active_ref_count > 1:
107
+ return self
108
+
109
+ if not self._configuration.actor_events_ws_url:
111
110
  logger.debug('APIFY_ACTOR_EVENTS_WS_URL env var not set, no events from Apify platform will be emitted.')
111
+ return self
112
+
113
+ # The future has to exist before the task that resolves it starts running.
114
+ self._connected_to_platform_websocket = asyncio.Future()
115
+ self._process_platform_messages_task = asyncio.create_task(
116
+ self._process_platform_messages(self._configuration.actor_events_ws_url)
117
+ )
118
+
119
+ try:
120
+ await self._connected_to_platform_websocket
121
+ except Exception as exc:
122
+ # Exit the already-entered parent so the recurring persist state task does not leak.
123
+ await self.__aexit__(None, None, None)
124
+ raise RuntimeError('Error connecting to platform events websocket!') from exc
125
+ except BaseException:
126
+ # Cancellation has to clean up as well. A stale task left behind would make the next entry look nested,
127
+ # returning a manager that silently receives no platform events at all.
128
+ await self.__aexit__(None, None, None)
129
+ raise
112
130
 
113
131
  return self
114
132
 
@@ -119,17 +137,35 @@ class ApifyEventManager(EventManager):
119
137
  exc_value: BaseException | None,
120
138
  exc_traceback: TracebackType | None,
121
139
  ) -> None:
122
- # Cancel the task before closing the websocket so that the closed connection is not treated as a drop
123
- # and followed by a reconnect attempt.
124
- if self._process_platform_messages_task and not self._process_platform_messages_task.done():
125
- self._process_platform_messages_task.cancel()
126
- with contextlib.suppress(asyncio.CancelledError):
127
- await self._process_platform_messages_task
140
+ """Close the event manager upon exiting the async context.
128
141
 
129
- if self._platform_events_websocket:
130
- await self._platform_events_websocket.close()
131
-
132
- await super().__aexit__(exc_type, exc_value, exc_traceback)
142
+ On the outermost exit, it stops consuming the platform messages and closes the websocket connection.
143
+ """
144
+ try:
145
+ if self._active_ref_count == 1:
146
+ await self._teardown_platform_websocket()
147
+ finally:
148
+ # The parent context has to be left even if the shutdown above fails. Staying active would mean never
149
+ # emitting `PersistState` again, as re-entering the context would be a no-op.
150
+ await super().__aexit__(exc_type, exc_value, exc_traceback)
151
+
152
+ async def _teardown_platform_websocket(self) -> None:
153
+ """Stop consuming the platform messages and close the websocket connection to the platform events."""
154
+ try:
155
+ # Cancel the task before closing the websocket so that the closed connection is not treated as a drop
156
+ # and followed by a reconnect attempt.
157
+ if self._process_platform_messages_task and not self._process_platform_messages_task.done():
158
+ self._process_platform_messages_task.cancel()
159
+ with contextlib.suppress(asyncio.CancelledError):
160
+ await self._process_platform_messages_task
161
+
162
+ if self._platform_events_websocket:
163
+ await self._platform_events_websocket.close()
164
+ finally:
165
+ # Leave no closed connection or resolved future behind, so that the context can be entered again.
166
+ self._process_platform_messages_task = None
167
+ self._platform_events_websocket = None
168
+ self._connected_to_platform_websocket = None
133
169
 
134
170
  def _process_connection_exception(self, exc: Exception) -> Exception | None:
135
171
  """Decide whether a failed connection attempt to the platform websocket should be retried.
@@ -159,7 +195,7 @@ class ApifyEventManager(EventManager):
159
195
  async for websocket in connections:
160
196
  self._platform_events_websocket = websocket
161
197
  if self._connected_to_platform_websocket and not self._connected_to_platform_websocket.done():
162
- self._connected_to_platform_websocket.set_result(True)
198
+ self._connected_to_platform_websocket.set_result(None)
163
199
  else:
164
200
  logger.info('Reconnected to the platform events websocket.')
165
201
 
@@ -178,10 +214,12 @@ class ApifyEventManager(EventManager):
178
214
  backoff_delays = websockets.client.backoff()
179
215
  else:
180
216
  await asyncio.sleep(next(backoff_delays))
181
- except Exception:
217
+ except Exception as exc:
182
218
  logger.exception('Error in websocket connection')
219
+
183
220
  if self._connected_to_platform_websocket is not None and not self._connected_to_platform_websocket.done():
184
- self._connected_to_platform_websocket.set_result(False)
221
+ # `__aenter__` is still waiting for the first connection, so let it fail with this as the cause.
222
+ self._connected_to_platform_websocket.set_exception(exc)
185
223
 
186
224
  async def _consume_messages(self, websocket: websockets.asyncio.client.ClientConnection) -> bool:
187
225
  """Handle platform messages until the connection closes; return whether it was lost vs. closed cleanly."""
@@ -12,6 +12,9 @@ if TYPE_CHECKING:
12
12
 
13
13
  logger = getLogger(__name__)
14
14
 
15
+ SUBMITTED_PRUNE_THRESHOLD = 128
16
+ """How many `submit_coro` futures may pile up before the finished ones are dropped from the tracking list."""
17
+
15
18
 
16
19
  class AsyncThread:
17
20
  """Run an asyncio event loop in a dedicated background thread.
@@ -26,6 +29,9 @@ class AsyncThread:
26
29
  self._default_timeout = default_timeout
27
30
  self._eventloop = asyncio.new_event_loop()
28
31
 
32
+ self._submitted: list[futures.Future] = []
33
+ """Futures of the coroutines submitted via `submit_coro` that may still be running."""
34
+
29
35
  # Start the event loop in a dedicated daemon thread.
30
36
  self._thread = threading.Thread(
31
37
  target=self._start_event_loop,
@@ -74,6 +80,55 @@ class AsyncThread:
74
80
  future.cancel()
75
81
  raise
76
82
 
83
+ def submit_coro(self, coro: Coroutine) -> futures.Future:
84
+ """Schedule a coroutine on the event loop without waiting for its result.
85
+
86
+ Use this for work nothing depends on, so the calling thread is not blocked by the round trip. Failures
87
+ are logged, as there is no caller to propagate them to, and `close` cancels whatever is still pending -
88
+ call `wait_for_submitted` first if that matters.
89
+
90
+ Args:
91
+ coro: The coroutine to run.
92
+
93
+ Returns:
94
+ The future of the scheduled coroutine, for callers that want to inspect its outcome later.
95
+
96
+ Raises:
97
+ RuntimeError: If the event loop has been closed.
98
+ """
99
+ if self._eventloop.is_closed():
100
+ raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
101
+
102
+ # Callers may go a long time between `wait_for_submitted` calls, so without pruning the list would hold
103
+ # every coroutine ever submitted, with its result.
104
+ if len(self._submitted) >= SUBMITTED_PRUNE_THRESHOLD:
105
+ self._submitted = [submitted for submitted in self._submitted if not submitted.done()]
106
+
107
+ future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
108
+ future.add_done_callback(self._log_failure)
109
+ self._submitted.append(future)
110
+
111
+ return future
112
+
113
+ def wait_for_submitted(self, timeout: timedelta | None = None) -> None:
114
+ """Block until the coroutines submitted via `submit_coro` have finished.
115
+
116
+ Use this before anything that would observe their effects, or before `close`, which cancels whatever is
117
+ still running. Coroutines that do not finish within the timeout stay tracked for the next call.
118
+
119
+ Args:
120
+ timeout: The maximum time to wait for the submitted coroutines. Pass `None` to use the
121
+ `default_timeout` passed to the constructor.
122
+ """
123
+ if timeout is None:
124
+ timeout = self._default_timeout
125
+
126
+ self._submitted = list(futures.wait(self._submitted, timeout=timeout.total_seconds()).not_done)
127
+
128
+ # Callers rely on the effects having landed, so a timeout has to be visible.
129
+ if self._submitted:
130
+ logger.warning(f'{len(self._submitted)} submitted coroutines did not finish within the timeout.')
131
+
77
132
  def close(self, timeout: timedelta | None = None) -> None:
78
133
  """Close the event loop and its thread gracefully.
79
134
 
@@ -110,6 +165,15 @@ class AsyncThread:
110
165
  logger.warning('Event loop thread did not exit cleanly! Forcing shutdown...')
111
166
  self._force_exit_event_loop()
112
167
 
168
+ @staticmethod
169
+ def _log_failure(future: futures.Future) -> None:
170
+ """Log the failure of a coroutine submitted via `submit_coro`."""
171
+ if future.cancelled():
172
+ return
173
+
174
+ if (exc := future.exception()) is not None:
175
+ logger.error('A coroutine submitted to the event loop failed.', exc_info=exc)
176
+
113
177
  def _start_event_loop(self) -> None:
114
178
  """Set up and run the asyncio event loop in the dedicated thread."""
115
179
  asyncio.set_event_loop(self._eventloop)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from crawlee._utils.log import LoggerOnce
6
+
7
+ logger_once = LoggerOnce(logging.getLogger(__name__))
8
+ """Process-wide deduplication: the scheduler and the graceful-stop extension share the one message below."""
9
+
10
+
11
+ def warn_about_uninitialized_actor() -> None:
12
+ """Warn, once per process, that the integration cannot react to the Actor run being migrated or aborted.
13
+
14
+ The scheduler and the graceful-stop extension both need an initialized Actor to listen for its events, and both
15
+ run without one in a plain `scrapy crawl`. One message covers them both.
16
+ """
17
+ logger_once.log(
18
+ 'The Actor is not initialized, so the Scrapy integration cannot react to a migration or an abort of the '
19
+ 'Actor run; the requests Scrapy is working on when the run is interrupted stay pending in the request queue.',
20
+ key='uninitialized-actor',
21
+ level=logging.WARNING,
22
+ )
@@ -0,0 +1,4 @@
1
+ from apify.scrapy.extensions._graceful_stop import ApifyGracefulStopExtension
2
+ from apify.scrapy.extensions._httpcache import ApifyCacheStorage
3
+
4
+ __all__ = ['ApifyCacheStorage', 'ApifyGracefulStopExtension']
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import suppress
4
+ from logging import getLogger
5
+ from typing import TYPE_CHECKING
6
+
7
+ from scrapy import signals
8
+
9
+ from apify import Actor, Event
10
+ from apify.scrapy._warnings import warn_about_uninitialized_actor
11
+
12
+ if TYPE_CHECKING:
13
+ from scrapy.crawler import Crawler
14
+
15
+ logger = getLogger(__name__)
16
+
17
+
18
+ class ApifyGracefulStopExtension:
19
+ """A Scrapy extension that stops the crawl gracefully when the Actor run is being aborted.
20
+
21
+ A graceful abort gives the run a moment before it is killed. The extension uses it to stop the engine: no new
22
+ requests are started, the ones in flight finish along with their callbacks and item pipelines, and the
23
+ scheduler marks them as handled in the request queue, so resurrecting the run does not download them again.
24
+ A migration is handled by `ApifyScheduler` instead: the platform restarts a migrating run only if its process
25
+ does not exit on its own, so the scheduler keeps the crawl running with nothing to do rather than stopping it.
26
+ """
27
+
28
+ def __init__(self, crawler: Crawler) -> None:
29
+ self._crawler = crawler
30
+
31
+ @classmethod
32
+ def from_crawler(cls, crawler: Crawler) -> ApifyGracefulStopExtension:
33
+ """Create the extension and hook it up to the spider's lifecycle."""
34
+ extension = cls(crawler)
35
+ crawler.signals.connect(extension.spider_opened, signal=signals.spider_opened)
36
+ crawler.signals.connect(extension.spider_closed, signal=signals.spider_closed)
37
+ return extension
38
+
39
+ def spider_opened(self) -> None:
40
+ """Start listening for the abort of the Actor run."""
41
+ try:
42
+ Actor.on(Event.ABORTING, self._on_aborting)
43
+ except RuntimeError:
44
+ warn_about_uninitialized_actor()
45
+
46
+ def spider_closed(self) -> None:
47
+ """Stop listening for the abort of the Actor run."""
48
+ # Without an initialized Actor (never initialized, or exited already) there is nothing to unregister from.
49
+ with suppress(RuntimeError):
50
+ Actor.off(Event.ABORTING, self._on_aborting)
51
+
52
+ async def _on_aborting(self) -> None:
53
+ """Stop the crawler; it waits for the requests in flight and closes the scheduler, which marks them."""
54
+ logger.info('The Actor run is being aborted: stopping the crawl gracefully.')
55
+ await self._crawler.stop_async()
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from copy import deepcopy
4
4
  from logging import getLogger
5
- from typing import Any, cast
5
+ from typing import TYPE_CHECKING, Any, get_args
6
6
 
7
7
  from scrapy import Request as ScrapyRequest
8
8
  from scrapy import Spider
@@ -11,11 +11,15 @@ from scrapy.utils.misc import load_object
11
11
  from scrapy.utils.request import request_from_dict
12
12
 
13
13
  from crawlee._request import UserData
14
- from crawlee._types import HttpHeaders
14
+ from crawlee._types import HttpHeaders, HttpMethod
15
+ from crawlee._utils.requests import compute_unique_key
15
16
 
16
17
  from ._serialization import decode_from_json, encode_to_json
17
18
  from apify import Request as ApifyRequest
18
19
 
20
+ if TYPE_CHECKING:
21
+ from typing_extensions import TypeIs
22
+
19
23
  logger = getLogger(__name__)
20
24
 
21
25
 
@@ -47,6 +51,38 @@ def _ensure_known_request_class(request_dict: dict[str, Any]) -> None:
47
51
  )
48
52
 
49
53
 
54
+ def _is_http_method(method: str) -> TypeIs[HttpMethod]:
55
+ """Tell whether `method` is one of the HTTP methods the RQ accepts; Scrapy itself takes any string."""
56
+ return method in get_args(HttpMethod)
57
+
58
+
59
+ def _compute_fingerprint(scrapy_request: ScrapyRequest) -> str:
60
+ """Identify the request an RQ unique key was minted for.
61
+
62
+ `to_scrapy_request` stamps this beside the unique key so `to_apify_request` can tell a request that came out
63
+ of the RQ from one Scrapy derived from it. It covers the URL, the method and the body, so a redirect, a
64
+ method switch and a changed body all read as a different request rather than the parent.
65
+
66
+ Headers are left out even though the unique key covers them: `DefaultHeadersMiddleware` and
67
+ `UserAgentMiddleware` call `headers.setdefault()` on the request in place before the scheduler sees it
68
+ again, so an otherwise untouched request would no longer match its own stamp.
69
+ """
70
+ method = scrapy_request.method
71
+ if not _is_http_method(method):
72
+ raise ValueError(
73
+ f'Unsupported HTTP method {method!r} in {scrapy_request}; '
74
+ f'the request queue accepts {", ".join(get_args(HttpMethod))}.'
75
+ )
76
+
77
+ return compute_unique_key(
78
+ url=scrapy_request.url,
79
+ method=method,
80
+ payload=scrapy_request.body,
81
+ keep_url_fragment=False,
82
+ use_extended_unique_key=True,
83
+ )
84
+
85
+
50
86
  def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequest | None:
51
87
  """Convert a Scrapy request to an Apify request.
52
88
 
@@ -76,8 +112,14 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
76
112
  try:
77
113
  if scrapy_request.dont_filter:
78
114
  request_kwargs['always_enqueue'] = True
79
- elif scrapy_request.meta.get('apify_request_unique_key'):
80
- request_kwargs['unique_key'] = scrapy_request.meta['apify_request_unique_key']
115
+ elif unique_key := scrapy_request.meta.get('apify_request_unique_key'):
116
+ # Reuse the RQ unique key only while this is still the request it was minted for. Redirects
117
+ # (`Request.replace()`) and spiders forwarding `meta` to another URL inherit the stamp, and reusing
118
+ # it there deduplicates the derived request against its parent. A stamp without a fingerprint was
119
+ # set by hand, so it is taken at face value.
120
+ fingerprint = _compute_fingerprint(scrapy_request)
121
+ if scrapy_request.meta.get('apify_request_fingerprint', fingerprint) == fingerprint:
122
+ request_kwargs['unique_key'] = unique_key
81
123
 
82
124
  # Serialize the Scrapy request now, before `Request.from_url()` runs below. `from_url()` mutates the
83
125
  # `user_data` dict it receives in place (it injects a live `CrawleeRequestData` under `__crawlee`), and that
@@ -113,8 +155,9 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
113
155
  # the old behavior, which dropped such requests entirely.
114
156
  if isinstance(scrapy_request.headers, Headers):
115
157
  try:
116
- headers = cast('dict[str, str]', dict(scrapy_request.headers.to_unicode_dict()))
117
- request_kwargs['headers'] = HttpHeaders(headers)
158
+ # `to_unicode_dict()` yields str keys and values, but Scrapy types it as `UserDict[str | bytes, Any]`,
159
+ # so the mapping goes through the model's validator instead of a cast.
160
+ request_kwargs['headers'] = HttpHeaders.model_validate(scrapy_request.headers.to_unicode_dict())
118
161
  except UnicodeDecodeError:
119
162
  logger.warning(
120
163
  'Could not represent Scrapy request headers as Apify request headers (non-UTF-8 values); '
@@ -165,7 +208,7 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
165
208
  Returns:
166
209
  The converted Scrapy request.
167
210
  """
168
- if not isinstance(cast('Any', apify_request), ApifyRequest):
211
+ if not isinstance(apify_request, ApifyRequest):
169
212
  raise TypeError('apify_request must be an apify.Request instance')
170
213
 
171
214
  # If the apify_request comes from the Scrapy
@@ -187,21 +230,14 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
187
230
  if not isinstance(scrapy_request, ScrapyRequest):
188
231
  raise TypeError('scrapy_request must be an instance of the ScrapyRequest class')
189
232
 
190
- # Update the meta field with the meta field from the apify_request
191
- meta = scrapy_request.meta or {}
192
- meta.update({'apify_request_unique_key': apify_request.unique_key})
193
- # scrapy_request.meta is a property, so we have to set it like this
194
- scrapy_request._meta = meta # noqa: SLF001
195
-
196
233
  # If the apify_request comes directly from the Scrapy, typically start URLs.
197
234
  else:
198
- scrapy_request = ScrapyRequest(
199
- url=apify_request.url,
200
- method=apify_request.method,
201
- meta={
202
- 'apify_request_unique_key': apify_request.unique_key,
203
- },
204
- )
235
+ scrapy_request = ScrapyRequest(url=apify_request.url, method=apify_request.method)
236
+
237
+ # Stamp the unique key with a fingerprint of the request it belongs to, so `to_apify_request` can tell
238
+ # this request apart from the ones Scrapy derives from it.
239
+ scrapy_request.meta['apify_request_unique_key'] = apify_request.unique_key
240
+ scrapy_request.meta['apify_request_fingerprint'] = _compute_fingerprint(scrapy_request)
205
241
 
206
242
  # Add optional 'headers' field
207
243
  if apify_request.headers: