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.
- {apify-4.0.1b4 → apify-4.0.2}/CHANGELOG.md +14 -4
- {apify-4.0.1b4 → apify-4.0.2}/PKG-INFO +1 -1
- {apify-4.0.1b4 → apify-4.0.2}/pyproject.toml +2 -2
- {apify-4.0.1b4 → apify-4.0.2}/pyproject.toml.orig +2 -2
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_actor.py +4 -6
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/_apify_event_manager.py +66 -28
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_async_thread.py +64 -0
- apify-4.0.2/src/apify/scrapy/_warnings.py +22 -0
- apify-4.0.2/src/apify/scrapy/extensions/__init__.py +4 -0
- apify-4.0.2/src/apify/scrapy/extensions/_graceful_stop.py +55 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/requests.py +56 -20
- apify-4.0.2/src/apify/scrapy/scheduler.py +498 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/utils.py +3 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_dataset_client.py +3 -2
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_request_queue_shared_client.py +187 -19
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_request_queue_single_client.py +8 -2
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/_dataset_client.py +3 -2
- apify-4.0.1b4/src/apify/scrapy/extensions/__init__.py +0 -3
- apify-4.0.1b4/src/apify/scrapy/scheduler.py +0 -203
- {apify-4.0.1b4 → apify-4.0.2}/CONTRIBUTING.md +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/LICENSE +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/README.md +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_charging.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_configuration.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_consts.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_crypto.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_proxy_configuration.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_utils.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/_webhook.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/errors.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/_types.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/events/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/log.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/request_loaders/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/request_loaders/_apify_request_list.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/request_loaders/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_actor_runner.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_detection.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_logging_config.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/_serialization.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/extensions/_httpcache.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/middlewares/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/middlewares/apify_proxy.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/middlewares/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/pipelines/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/pipelines/actor_dataset_push.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/pipelines/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/scrapy/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_alias_resolving.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_api_client_creation.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_key_value_store_client.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_models.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_request_queue_client.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_storage_client.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/_utils.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_apify/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/_key_value_store_client.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_file_system/_storage_client.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_ppe_dataset_mixin.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_smart_apify/__init__.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/_smart_apify/_storage_client.py +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storage_clients/py.typed +0 -0
- {apify-4.0.1b4 → apify-4.0.2}/src/apify/storages/__init__.py +0 -0
- {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
|
-
|
|
6
|
-
|
|
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 -->
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "apify"
|
|
7
|
-
version = "4.0.
|
|
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.
|
|
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
|
-
|
|
1253
|
-
|
|
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[
|
|
93
|
-
"""
|
|
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
|
-
#
|
|
101
|
-
if self.
|
|
102
|
-
self
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
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
|
-
|
|
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,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,
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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(
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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:
|