scrapely-python 0.1.30__tar.gz → 0.1.32__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 (72) hide show
  1. scrapely_python-0.1.32/CHANGELOG.md +20 -0
  2. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/NOTICE +1 -0
  3. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/PKG-INFO +1 -1
  4. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/pyproject.toml +1 -1
  5. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/__init__.py +1 -1
  6. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/actor.py +4 -4
  7. scrapely_python-0.1.32/src/scrapely/scrapy/__init__.py +36 -0
  8. scrapely_python-0.1.32/src/scrapely/scrapy/_actor_runner.py +43 -0
  9. scrapely_python-0.1.32/src/scrapely/scrapy/_async_thread.py +209 -0
  10. scrapely_python-0.1.32/src/scrapely/scrapy/_logging_config.py +70 -0
  11. scrapely_python-0.1.32/src/scrapely/scrapy/_serialization.py +147 -0
  12. scrapely_python-0.1.32/src/scrapely/scrapy/_warnings.py +26 -0
  13. scrapely_python-0.1.32/src/scrapely/scrapy/extensions/__init__.py +8 -0
  14. scrapely_python-0.1.32/src/scrapely/scrapy/extensions/_graceful_stop.py +59 -0
  15. scrapely_python-0.1.32/src/scrapely/scrapy/extensions/_httpcache.py +321 -0
  16. scrapely_python-0.1.32/src/scrapely/scrapy/middlewares/__init__.py +7 -0
  17. scrapely_python-0.1.32/src/scrapely/scrapy/middlewares/apify_proxy.py +140 -0
  18. scrapely_python-0.1.32/src/scrapely/scrapy/pipelines/__init__.py +7 -0
  19. scrapely_python-0.1.32/src/scrapely/scrapy/pipelines/actor_dataset_push.py +30 -0
  20. scrapely_python-0.1.32/src/scrapely/scrapy/requests.py +254 -0
  21. scrapely_python-0.1.32/src/scrapely/scrapy/scheduler.py +500 -0
  22. scrapely_python-0.1.32/src/scrapely/scrapy/utils.py +60 -0
  23. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/client.py +30 -7
  24. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  25. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely_python.egg-info/SOURCES.txt +16 -0
  26. scrapely_python-0.1.30/CHANGELOG.md +0 -12
  27. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/LICENSE +0 -0
  28. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/LICENSE-APACHE +0 -0
  29. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/MANIFEST.in +0 -0
  30. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/README.md +0 -0
  31. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/setup.cfg +0 -0
  32. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/_browser_locations.py +0 -0
  33. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/browser_configuration.py +0 -0
  34. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/cdp_retry.py +0 -0
  35. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/charging.py +0 -0
  36. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/charging_client.py +0 -0
  37. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/configuration.py +0 -0
  38. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/crypto/__init__.py +0 -0
  39. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/crypto/_secrets.py +0 -0
  40. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/errors.py +0 -0
  41. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/events/__init__.py +0 -0
  42. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/events/_types.py +0 -0
  43. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/events/event_manager.py +0 -0
  44. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/log.py +0 -0
  45. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/patch_browser_pool.py +0 -0
  46. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/pricing_types.py +0 -0
  47. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/proxy_configuration.py +0 -0
  48. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/py.typed +0 -0
  49. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/request_loaders/__init__.py +0 -0
  50. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  51. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/solver.py +0 -0
  52. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/__init__.py +0 -0
  53. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/_file_system/__init__.py +0 -0
  54. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  55. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  56. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/_metadata.py +0 -0
  57. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/api_client.py +0 -0
  58. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/patch_storage.py +0 -0
  59. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  60. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  61. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  62. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  63. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  64. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/types.py +0 -0
  65. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage/utils.py +0 -0
  66. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storage_clients/__init__.py +0 -0
  67. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/storages/__init__.py +0 -0
  68. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/types.py +0 -0
  69. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely/webhook.py +0 -0
  70. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  71. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely_python.egg-info/requires.txt +0 -0
  72. {scrapely_python-0.1.30 → scrapely_python-0.1.32}/src/scrapely_python.egg-info/top_level.txt +0 -0
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.32] - 2026-10-20
6
+
7
+ - Added `scrapely.scrapy`, the Scrapy integration: `run_scrapy_actor`, `initialize_logging`, `apply_apify_settings`, a scheduler backed by the run's request queue, a pipeline that pushes items to the default dataset, a proxy middleware, an HTTP cache storage and an extension that stops the crawl gracefully when the run is aborted. Queued Scrapy requests and cached responses are stored as JSON, never pickled, and a stored request only rebuilds as a `scrapy.Request` subclass. A request leaves the queue as handled only once Scrapy has finished it, so a migrated or aborted run does not lose or repeat it. Scrapy is imported only when these are used.
8
+
9
+ ## [0.1.31] - 2026-10-20
10
+
11
+ - The storage client percent-encodes request ids and key-value store record keys as one URL path segment and refuses `''`, `.` and `..`. A request id such as `..` read from a queue used to send the call to another resource.
12
+
13
+ ## [0.1.30] - 2026-10-20
14
+
15
+ - Removed `apify_margin_percentage` from the pricing info models; the API no longer returns it.
16
+ - Added Apache-2.0 change notices to the storage clients, storage utils and log module derived from apify-sdk-python, and listed them in NOTICE.
17
+
18
+ ## [0.1.29] - 2026-10-19
19
+
20
+ - Initial public release.
@@ -10,6 +10,7 @@ Version 2.0 (see LICENSE-APACHE). They have been modified by TeraShift GmbH:
10
10
  - src/scrapely/events/event_manager.py
11
11
  - src/scrapely/log.py
12
12
  - src/scrapely/request_loaders/_scrapely_request_list.py
13
+ - src/scrapely/scrapy/ (all files)
13
14
  - src/scrapely/storage/_file_system/_key_value_store_client.py
14
15
  - src/scrapely/storage/_file_system/_storage_client.py
15
16
  - src/scrapely/storage/smart_scrapely_storage_client.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.30
3
+ Version: 0.1.32
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT AND Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.30"
7
+ version = "0.1.32"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT AND Apache-2.0"
@@ -227,7 +227,7 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
227
227
  set_api_storage,
228
228
  )
229
229
 
230
- __version__ = '0.1.30'
230
+ __version__ = '0.1.32'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
@@ -1756,10 +1756,10 @@ class Actor(metaclass=_ActorMeta):
1756
1756
 
1757
1757
  opts = options or MetamorphOptions()
1758
1758
 
1759
- # Wire format (server contract): the server copies the current INPUT to
1760
- # INPUT-METAMORPH-<n> itself and tracks the metamorph count in run
1761
- # stats; the new input (if any) is read from the request body's
1762
- # `input` field. targetActorId/build are accepted in the body.
1759
+ # Wire format (server contract): the new input (if any) goes in the
1760
+ # request body's `input` field; the server stores it as
1761
+ # INPUT-METAMORPH-<n> (INPUT keeps the original) and tracks the
1762
+ # metamorph count in run stats. targetActorId/build are accepted in the body.
1763
1763
  client = cls.new_client()
1764
1764
  body: Dict[str, Any] = {'targetActorId': target_actor_id}
1765
1765
  if opts.build:
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/__init__.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ from crawlee._utils.try_import import install_import_hook as _install_import_hook
6
+ from crawlee._utils.try_import import try_import as _try_import
7
+
8
+ _install_import_hook(__name__)
9
+
10
+ # The following imports use try_import to handle optional dependencies, as they may not always be available.
11
+
12
+ with _try_import(__name__, 'run_scrapy_actor'):
13
+ from ._actor_runner import run_scrapy_actor
14
+
15
+ with _try_import(__name__, 'initialize_logging'):
16
+ from ._logging_config import initialize_logging
17
+
18
+ with _try_import(__name__, 'to_apify_request', 'to_scrapy_request'):
19
+ from .requests import to_apify_request, to_scrapy_request
20
+
21
+ with _try_import(__name__, 'ApifyScheduler'):
22
+ from .scheduler import ApifyScheduler
23
+
24
+ with _try_import(__name__, 'apply_apify_settings', 'get_basic_auth_header'):
25
+ from .utils import apply_apify_settings, get_basic_auth_header
26
+
27
+
28
+ __all__ = [
29
+ 'ApifyScheduler',
30
+ 'apply_apify_settings',
31
+ 'get_basic_auth_header',
32
+ 'initialize_logging',
33
+ 'run_scrapy_actor',
34
+ 'to_apify_request',
35
+ 'to_scrapy_request',
36
+ ]
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/_actor_runner.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Coroutine
12
+
13
+
14
+ def run_scrapy_actor(coro: Coroutine) -> None:
15
+ """Start Twisted's reactor and execute the provided Actor coroutine.
16
+
17
+ This function installs Twisted's asyncio-compatible reactor, then initiates it and runs the given asyncio
18
+ coroutine (typically the Actor's main) by converting it to a Deferred. This bridges the asyncio and Twisted
19
+ event loops, enabling the Scrapely and Scrapy integration to work together.
20
+ """
21
+ from scrapy.utils.reactor import install_reactor # noqa: PLC0415
22
+ from twisted.internet.error import ReactorAlreadyInstalledError # noqa: PLC0415
23
+
24
+ try:
25
+ install_reactor('twisted.internet.asyncioreactor.AsyncioSelectorReactor')
26
+ except ReactorAlreadyInstalledError:
27
+ from twisted.internet import reactor as installed_reactor # noqa: PLC0415
28
+
29
+ reactor_cls = type(installed_reactor)
30
+ if (
31
+ reactor_cls.__module__ != 'twisted.internet.asyncioreactor'
32
+ or reactor_cls.__name__ != 'AsyncioSelectorReactor'
33
+ ):
34
+ raise RuntimeError(
35
+ 'A Twisted reactor is already installed and it is not AsyncioSelectorReactor. '
36
+ 'Make sure that run_scrapy_actor() is called before importing any Scrapy or Twisted '
37
+ 'modules that install a reactor (e.g. scrapy.crawler).'
38
+ ) from None
39
+
40
+ from twisted.internet.defer import Deferred # noqa: PLC0415
41
+ from twisted.internet.task import react # noqa: PLC0415
42
+
43
+ react(lambda _reactor: Deferred.fromFuture(asyncio.ensure_future(coro)))
@@ -0,0 +1,209 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/_async_thread.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import threading
9
+ from concurrent import futures
10
+ from datetime import timedelta
11
+ from logging import getLogger
12
+ from typing import TYPE_CHECKING, Any, Literal
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Coroutine
16
+
17
+ logger = getLogger(__name__)
18
+
19
+ SUBMITTED_PRUNE_THRESHOLD = 128
20
+ """How many `submit_coro` futures may pile up before the finished ones are dropped from the tracking list."""
21
+
22
+
23
+ class AsyncThread:
24
+ """Run an asyncio event loop in a dedicated background thread.
25
+
26
+ This lets synchronous Scrapy callbacks drive asynchronous Scrapely and Crawlee coroutines. The
27
+ scheduler and the HTTP cache storage each own their own `AsyncThread`, so the request queue and
28
+ the key-value store never share an event loop; they only share the read-only global
29
+ `Configuration`. A single shared loop would also work but would couple their lifecycles.
30
+ """
31
+
32
+ def __init__(self, default_timeout: timedelta = timedelta(seconds=60)) -> None:
33
+ self._default_timeout = default_timeout
34
+ self._eventloop = asyncio.new_event_loop()
35
+
36
+ self._submitted: list[futures.Future] = []
37
+ """Futures of the coroutines submitted via `submit_coro` that may still be running."""
38
+
39
+ # Start the event loop in a dedicated daemon thread.
40
+ self._thread = threading.Thread(
41
+ target=self._start_event_loop,
42
+ daemon=True,
43
+ )
44
+ self._thread.start()
45
+
46
+ def run_coro(
47
+ self,
48
+ coro: Coroutine,
49
+ timeout: timedelta | Literal['default'] = 'default',
50
+ ) -> Any:
51
+ """Run a coroutine on an event loop running in a separate thread.
52
+
53
+ This method schedules the coroutine to run on the event loop and blocks until the coroutine completes
54
+ or the specified timeout is reached.
55
+
56
+ Args:
57
+ coro: The coroutine to run.
58
+ timeout: The maximum time to wait for the coroutine to finish. Pass `'default'` to use the
59
+ `default_timeout` passed to the constructor.
60
+
61
+ Returns:
62
+ The result returned by the coroutine.
63
+
64
+ Raises:
65
+ RuntimeError: If the event loop has been closed.
66
+ TimeoutError: If the coroutine does not complete within the timeout.
67
+ Exception: Any exception raised during coroutine execution.
68
+ """
69
+ if timeout == 'default':
70
+ timeout = self._default_timeout
71
+
72
+ if self._eventloop.is_closed():
73
+ raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
74
+
75
+ # Submit the coroutine to the event loop running in the other thread.
76
+ future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
77
+ try:
78
+ # Wait for the coroutine's result until the specified timeout.
79
+ return future.result(timeout=timeout.total_seconds())
80
+ except futures.TimeoutError:
81
+ # `future.result` gave up, but the coroutine keeps running on the loop; cancel it so it does
82
+ # not outlive the timeout. The propagated error is logged once by the caller (or Scrapy), so
83
+ # this method does not log it itself.
84
+ future.cancel()
85
+ raise
86
+
87
+ def submit_coro(self, coro: Coroutine) -> futures.Future:
88
+ """Schedule a coroutine on the event loop without waiting for its result.
89
+
90
+ Use this for work nothing depends on, so the calling thread is not blocked by the round trip. Failures
91
+ are logged, as there is no caller to propagate them to, and `close` cancels whatever is still pending -
92
+ call `wait_for_submitted` first if that matters.
93
+
94
+ Args:
95
+ coro: The coroutine to run.
96
+
97
+ Returns:
98
+ The future of the scheduled coroutine, for callers that want to inspect its outcome later.
99
+
100
+ Raises:
101
+ RuntimeError: If the event loop has been closed.
102
+ """
103
+ if self._eventloop.is_closed():
104
+ raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
105
+
106
+ # Callers may go a long time between `wait_for_submitted` calls, so without pruning the list would hold
107
+ # every coroutine ever submitted, with its result.
108
+ if len(self._submitted) >= SUBMITTED_PRUNE_THRESHOLD:
109
+ self._submitted = [submitted for submitted in self._submitted if not submitted.done()]
110
+
111
+ future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
112
+ future.add_done_callback(self._log_failure)
113
+ self._submitted.append(future)
114
+
115
+ return future
116
+
117
+ def wait_for_submitted(self, timeout: timedelta | None = None) -> None:
118
+ """Block until the coroutines submitted via `submit_coro` have finished.
119
+
120
+ Use this before anything that would observe their effects, or before `close`, which cancels whatever is
121
+ still running. Coroutines that do not finish within the timeout stay tracked for the next call.
122
+
123
+ Args:
124
+ timeout: The maximum time to wait for the submitted coroutines. Pass `None` to use the
125
+ `default_timeout` passed to the constructor.
126
+ """
127
+ if timeout is None:
128
+ timeout = self._default_timeout
129
+
130
+ self._submitted = list(futures.wait(self._submitted, timeout=timeout.total_seconds()).not_done)
131
+
132
+ # Callers rely on the effects having landed, so a timeout has to be visible.
133
+ if self._submitted:
134
+ logger.warning(f'{len(self._submitted)} submitted coroutines did not finish within the timeout.')
135
+
136
+ def close(self, timeout: timedelta | None = None) -> None:
137
+ """Close the event loop and its thread gracefully.
138
+
139
+ This method cancels all pending tasks, stops the event loop, and waits for the thread to exit.
140
+ If the thread does not exit within the given timeout, a forced shutdown is attempted.
141
+
142
+ Args:
143
+ timeout: The maximum time to wait for the event loop thread to exit. Pass `None` to use the
144
+ `default_timeout` passed to the constructor.
145
+ """
146
+ if timeout is None:
147
+ timeout = self._default_timeout
148
+
149
+ # A repeated close (e.g. a retried shutdown) would call into the already-closed loop and raise
150
+ # `RuntimeError: Event loop is closed`. The loop closes itself once it stops, so a second close
151
+ # is a no-op.
152
+ if self._eventloop.is_closed():
153
+ return
154
+
155
+ try:
156
+ if self._eventloop.is_running():
157
+ # Cancel all pending tasks in the event loop, honouring the caller's timeout.
158
+ self.run_coro(self._shutdown_tasks(), timeout=timeout)
159
+ finally:
160
+ # Stop the loop and join its thread even if cancelling the pending tasks above raised or timed
161
+ # out. Skipping this would leave the loop running and leak its thread.
162
+ self._eventloop.call_soon_threadsafe(self._eventloop.stop)
163
+
164
+ # Wait for the event loop thread to finish execution.
165
+ self._thread.join(timeout=timeout.total_seconds())
166
+
167
+ # If the thread is still running after the timeout, force a shutdown.
168
+ if self._thread.is_alive():
169
+ logger.warning('Event loop thread did not exit cleanly! Forcing shutdown...')
170
+ self._force_exit_event_loop()
171
+
172
+ @staticmethod
173
+ def _log_failure(future: futures.Future) -> None:
174
+ """Log the failure of a coroutine submitted via `submit_coro`."""
175
+ if future.cancelled():
176
+ return
177
+
178
+ if (exc := future.exception()) is not None:
179
+ logger.error('A coroutine submitted to the event loop failed.', exc_info=exc)
180
+
181
+ def _start_event_loop(self) -> None:
182
+ """Set up and run the asyncio event loop in the dedicated thread."""
183
+ asyncio.set_event_loop(self._eventloop)
184
+ try:
185
+ self._eventloop.run_forever()
186
+ finally:
187
+ self._eventloop.close()
188
+ logger.debug('Asyncio event loop has been closed.')
189
+
190
+ async def _shutdown_tasks(self) -> None:
191
+ """Cancel all pending tasks in the event loop."""
192
+ # Retrieve all tasks for the event loop, excluding the current task.
193
+ tasks = [task for task in asyncio.all_tasks(self._eventloop) if task is not asyncio.current_task()]
194
+
195
+ # Cancel each pending task.
196
+ for task in tasks:
197
+ task.cancel()
198
+
199
+ # Wait until all tasks have been cancelled or finished.
200
+ await asyncio.gather(*tasks, return_exceptions=True)
201
+
202
+ def _force_exit_event_loop(self) -> None:
203
+ """Shut down the event loop and its thread forcefully."""
204
+ try:
205
+ logger.info('Forced shutdown of the event loop and its thread...')
206
+ self._eventloop.call_soon_threadsafe(self._eventloop.stop)
207
+ self._thread.join(timeout=5)
208
+ except Exception:
209
+ logger.exception('Exception occurred during forced event loop shutdown.')
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/_logging_config.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from scrapy.utils import log as scrapy_logging
11
+ from scrapy.utils.project import get_project_settings
12
+
13
+ from scrapely.log import ActorLogFormatter
14
+
15
+ # Define logger names.
16
+ _PRIMARY_LOGGERS = ['scrapely', 'scrapely_client', 'scrapy']
17
+ _SUPPLEMENTAL_LOGGERS = ['filelock', 'hpack', 'httpcore', 'protego', 'twisted']
18
+ _ALL_LOGGERS = _PRIMARY_LOGGERS + _SUPPLEMENTAL_LOGGERS
19
+
20
+ # Mutable state shared with the Scrapy monkey-patch below. `initialize_logging` refreshes
21
+ # `level`/`handler` on each call; the patch (installed once) reads them so it always applies the
22
+ # latest configuration rather than values captured the first time it ran.
23
+ _state: dict[str, Any] = {'level': 'INFO', 'handler': None, 'patched': False}
24
+
25
+
26
+ def _configure_logger(name: str | None, logging_level: str, handler: logging.Handler) -> None:
27
+ """Clear and reconfigure the logger."""
28
+ logger = logging.getLogger(name)
29
+ logger.handlers.clear()
30
+ logger.setLevel(logging_level)
31
+ logger.addHandler(handler)
32
+ logger.propagate = False
33
+
34
+
35
+ def _configure_all_loggers() -> None:
36
+ """Apply the Scrapely handler and level to the root logger and all defined loggers."""
37
+ handler = _state['handler']
38
+ if handler is None:
39
+ return
40
+ for logger_name in [None, *_ALL_LOGGERS]:
41
+ _configure_logger(logger_name, _state['level'], handler)
42
+
43
+
44
+ def initialize_logging() -> None:
45
+ """Configure logging for Scrapely Actors and adjust Scrapy's logging settings."""
46
+ # Retrieve Scrapy project settings and determine the logging level.
47
+ settings = get_project_settings()
48
+ _state['level'] = settings.get('LOG_LEVEL', 'INFO') # Default to INFO.
49
+
50
+ # Create a custom handler with the Scrapely log formatter.
51
+ handler = logging.StreamHandler()
52
+ handler.setFormatter(ActorLogFormatter(include_logger_name=True))
53
+ _state['handler'] = handler
54
+
55
+ # Configure the root logger and all other defined loggers.
56
+ _configure_all_loggers()
57
+
58
+ # Monkey-patch Scrapy's logging to re-apply our settings whenever it reconfigures logging.
59
+ # Install the wrapper at most once, otherwise repeated calls would nest wrappers.
60
+ if _state['patched']:
61
+ return
62
+
63
+ original_configure_logging = scrapy_logging.configure_logging
64
+
65
+ def new_configure_logging(*args: Any, **kwargs: Any) -> None:
66
+ original_configure_logging(*args, **kwargs)
67
+ _configure_all_loggers()
68
+
69
+ scrapy_logging.configure_logging = new_configure_logging # ty: ignore[invalid-assignment]
70
+ _state['patched'] = True
@@ -0,0 +1,147 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/_serialization.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ """JSON serialization of Scrapy requests and cached responses for storage on the Scrapely platform.
6
+
7
+ Scrapy requests and cached responses are stored in the request queue and key-value store, which hold JSON.
8
+ They are therefore serialized as JSON here (never pickled: unpickling stored data could run arbitrary code).
9
+
10
+ Only `body` (`bytes`) and `headers` (`{bytes: [bytes]}`) are not natively JSON-serializable. Both live at fixed keys
11
+ and are base64-encoded in place. A `str` `body` is encoded as UTF-8 bytes and deserialized as `bytes`, matching
12
+ Scrapy, which always stores `body` as `bytes`.
13
+
14
+ Pydantic models, such as Crawlee's `UserData`, are dumped with `model_dump(mode='json')`, which converts
15
+ non-JSON-native fields into JSON-compatible values. For example, `datetime` fields are stored as ISO-8601 strings.
16
+
17
+ All other values, notably `meta` and `cb_kwargs`, must already be JSON-serializable. Non-JSON-serializable values,
18
+ such as `datetime`, `set`, `Decimal`, or arbitrary objects, fail serialization with a clear error naming the
19
+ offending value. The request is skipped rather than stored in a corrupted form.
20
+
21
+ No in-band sentinel is used, so user values cannot collide with the encoding.
22
+
23
+ Known JSON limitations: values in `meta` and `cb_kwargs` are subject to JSON coercions. A `tuple` round-trips as
24
+ a `list`, and non-string `dict` keys round-trip as strings; for example, `{1: 'a'}` becomes `{'1': 'a'}`.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import json
31
+ from typing import Any
32
+
33
+ from pydantic import BaseModel
34
+
35
+ # Cap the offending value's repr in a serialization error message so a huge value cannot bloat the log.
36
+ _MAX_ERROR_VALUE_REPR_LEN = 200
37
+
38
+
39
+ def encode_to_json(data: dict[str, Any]) -> str:
40
+ """Serialize a Scrapy request/response dict to a JSON string.
41
+
42
+ The `body` and `headers` fields are base64-encoded in place (a `str` `body` via its UTF-8 bytes); pydantic
43
+ models are dumped to plain dicts. A `TypeError` is raised if any other value cannot be JSON-encoded.
44
+
45
+ Args:
46
+ data: The dict to serialize, e.g. the output of `scrapy.Request.to_dict()`.
47
+
48
+ Returns:
49
+ The JSON-encoded string.
50
+ """
51
+ if not isinstance(data, dict):
52
+ raise TypeError(f'Expected a dict to serialize, got {type(data)}')
53
+
54
+ safe = dict(data)
55
+
56
+ # `body` is base64-encoded so binary payloads survive; a `str` body is taken as its UTF-8 bytes, which keeps
57
+ # encode/decode symmetric (decode always base64-decodes `body` back to `bytes`).
58
+ body = safe.get('body')
59
+ if isinstance(body, (bytes, str)):
60
+ raw_body = body.encode('utf-8') if isinstance(body, str) else body
61
+ safe['body'] = base64.b64encode(raw_body).decode('ascii')
62
+
63
+ if isinstance(safe.get('headers'), dict):
64
+ safe['headers'] = _encode_headers(safe['headers'])
65
+
66
+ try:
67
+ # `ensure_ascii=False` keeps non-ASCII URLs/meta as their UTF-8 form instead of `\uXXXX` escapes, which
68
+ # would otherwise roughly double the size of non-Latin text in storage.
69
+ return json.dumps(safe, default=_json_default, ensure_ascii=False)
70
+ # `ValueError` covers pydantic's `PydanticSerializationError`, raised when a model field cannot be dumped
71
+ # to JSON even in JSON mode.
72
+ except (TypeError, ValueError) as exc:
73
+ raise TypeError(
74
+ 'Failed to JSON-serialize a Scrapy request/response for storage on the Scrapely platform. '
75
+ 'All values in `meta` and `cb_kwargs` must be JSON-serializable (str, int, float, bool, None, '
76
+ 'list, dict, or a pydantic model).'
77
+ ) from exc
78
+
79
+
80
+ def decode_from_json(text: str) -> Any:
81
+ """Reconstruct a Scrapy request/response dict from a string produced by `encode_to_json`.
82
+
83
+ The base64-encoded `body` and `headers` fields are decoded back to their `bytes` representation.
84
+
85
+ Args:
86
+ text: The JSON-encoded string.
87
+
88
+ Returns:
89
+ The decoded object (a dict for valid request/response payloads).
90
+ """
91
+ data = json.loads(text)
92
+ if not isinstance(data, dict):
93
+ return data
94
+
95
+ # `validate=True` makes a non-base64 body raise loudly instead of silently decoding to garbage.
96
+ if isinstance(data.get('body'), str):
97
+ data['body'] = base64.b64decode(data['body'], validate=True)
98
+
99
+ if isinstance(data.get('headers'), dict):
100
+ data['headers'] = _decode_headers(data['headers'])
101
+
102
+ return data
103
+
104
+
105
+ def _json_default(obj: Any) -> Any:
106
+ """Fallback for values `json.dumps` cannot serialize: pydantic models are dumped, anything else raises.
107
+
108
+ The error names the offending value (type and a truncated repr) so a failed serialization points straight
109
+ at the bad `meta`/`cb_kwargs` entry instead of just reporting that something failed.
110
+ """
111
+ if isinstance(obj, BaseModel):
112
+ return obj.model_dump(mode='json', by_alias=True)
113
+ value_repr = repr(obj)
114
+ if len(value_repr) > _MAX_ERROR_VALUE_REPR_LEN:
115
+ value_repr = value_repr[:_MAX_ERROR_VALUE_REPR_LEN] + '...'
116
+ raise TypeError(f'Object of type {type(obj).__name__} is not JSON-serializable: {value_repr}')
117
+
118
+
119
+ def _encode_headers(headers: dict[Any, Any]) -> dict[str, list[str]]:
120
+ """Encode a Scrapy `{bytes: [bytes]}` headers mapping to a JSON-safe `{str: [base64-str]}`."""
121
+ encoded: dict[str, list[str]] = {}
122
+ for key, value in headers.items():
123
+ str_key = key.decode('latin-1') if isinstance(key, bytes) else key
124
+ values = value if isinstance(value, (list, tuple)) else [value]
125
+ encoded[str_key] = [_b64encode_value(item) for item in values]
126
+ return encoded
127
+
128
+
129
+ def _decode_headers(headers: dict[str, Any]) -> dict[bytes, list[bytes]]:
130
+ """Reverse `_encode_headers`, restoring the `{bytes: [bytes]}` mapping Scrapy expects."""
131
+ decoded: dict[bytes, list[bytes]] = {}
132
+ for key, value in headers.items():
133
+ bytes_key = key.encode('latin-1') if isinstance(key, str) else key
134
+ values = value if isinstance(value, list) else [value]
135
+ decoded[bytes_key] = [base64.b64decode(item, validate=True) for item in values]
136
+ return decoded
137
+
138
+
139
+ def _b64encode_value(value: Any) -> str:
140
+ """Base64-encode a single header value.
141
+
142
+ Scrapy stores header values as `bytes`; a `str` is encoded as its UTF-8 bytes. Any other type is coerced with
143
+ `str()` as a lenient last resort. That coercion is lossy (e.g. `5` becomes `b'5'`), but Scrapy does not produce
144
+ non-`bytes`/`str` header values, so it is not hit on the real path.
145
+ """
146
+ raw = value if isinstance(value, bytes) else str(value).encode('utf-8')
147
+ return base64.b64encode(raw).decode('ascii')
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/_warnings.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ from crawlee._utils.log import LoggerOnce
10
+
11
+ logger_once = LoggerOnce(logging.getLogger(__name__))
12
+ """Process-wide deduplication: the scheduler and the graceful-stop extension share the one message below."""
13
+
14
+
15
+ def warn_about_uninitialized_actor() -> None:
16
+ """Warn, once per process, that the integration cannot react to the Actor run being migrated or aborted.
17
+
18
+ The scheduler and the graceful-stop extension both need an initialized Actor to listen for its events, and both
19
+ run without one in a plain `scrapy crawl`. One message covers them both.
20
+ """
21
+ logger_once.log(
22
+ 'The Actor is not initialized, so the Scrapy integration cannot react to a migration or an abort of the '
23
+ 'Actor run; the requests Scrapy is working on when the run is interrupted stay pending in the request queue.',
24
+ key='uninitialized-actor',
25
+ level=logging.WARNING,
26
+ )
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Derived from apify-sdk-python (src/apify/scrapy/extensions/__init__.py),
3
+ # Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
4
+ # Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
5
+ from scrapely.scrapy.extensions._graceful_stop import ApifyGracefulStopExtension
6
+ from scrapely.scrapy.extensions._httpcache import ApifyCacheStorage
7
+
8
+ __all__ = ['ApifyCacheStorage', 'ApifyGracefulStopExtension']