scrapely-python 0.1.22__py3-none-any.whl → 0.1.23__py3-none-any.whl

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.
scrapely/__init__.py CHANGED
@@ -10,22 +10,20 @@ Usage:
10
10
  await Actor.push_data({'url': actor_input['url']})
11
11
  """
12
12
 
13
- # Side-effect: patch Crawlee Playwright browser pool to use remote CDP (CDP_URL).
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ # Side-effect: patch browser launches (Crawlee BrowserPool, PlaywrightCrawler,
16
+ # raw Playwright, Camoufox, pyppeteer) to use the remote CDP browser (CDP_URL).
14
17
  # Must run on package import so `from scrapely import Actor` enables the patch
15
- # before PlaywrightCrawler launches a browser (Node SDK parity).
18
+ # before any browser is launched (Node SDK parity). It does not import Crawlee:
19
+ # the Crawlee patches are applied when Crawlee's browser modules load.
16
20
  from .patch_browser_pool import set_cdp_url # noqa: F401
17
21
 
18
- from crawlee import Request
19
-
20
- from crawlee.request_loaders import (
21
- RequestLoader,
22
- RequestManager,
23
- RequestManagerTandem,
24
- SitemapRequestLoader,
25
- )
26
- from crawlee.storages import Dataset, KeyValueStore, RequestQueue
27
- from crawlee.storage_clients.models import DatasetItemsListPage
28
-
22
+ # Crawlee is NOT imported here: it costs ~1 s per run under gVisor and many
23
+ # actors never touch it. Everything that needs Crawlee (the re-exported Crawlee
24
+ # classes, storage clients, events, request loaders) is resolved on first
25
+ # attribute access by ``__getattr__`` below; ``Actor`` imports what it needs on
26
+ # first use. Every ``from scrapely import X`` that worked before still works.
29
27
  from .actor import Actor
30
28
  from .configuration import Configuration
31
29
  from .charging_client import (
@@ -33,40 +31,6 @@ from .charging_client import (
33
31
  create_charging_client,
34
32
  push_data_charging_context,
35
33
  )
36
- from .storage import (
37
- ApiStorageClient,
38
- FileSystemStorageClient,
39
- MemoryStorageClient,
40
- ScrapelyStorageClient,
41
- SmartScrapelyStorageClient,
42
- StorageClient,
43
- set_api_storage,
44
- )
45
- from .request_loaders import ScrapelyRequestList
46
- from crawlee.events import EventListener
47
- from .events import (
48
- AbortingEvent,
49
- ActorEventTypes,
50
- CrawleeEventManager,
51
- DeprecatedEvent,
52
- Event,
53
- EventAbortingData,
54
- EventExitData,
55
- EventManager,
56
- EventMessage,
57
- EventMigratingData,
58
- EventPersistStateData,
59
- EventSystemInfoData,
60
- EventWithoutData,
61
- ExitEvent,
62
- LocalEventManager,
63
- MigratingEvent,
64
- PersistStateEvent,
65
- ScrapelyEventManager,
66
- SystemInfoEvent,
67
- SystemInfoEventData,
68
- UnknownEvent,
69
- )
70
34
  from .proxy_configuration import ProxyConfiguration
71
35
  from .browser_configuration import BrowserConfiguration
72
36
  from .charging import (
@@ -132,7 +96,138 @@ from .errors import (
132
96
  raise_for_status,
133
97
  )
134
98
 
135
- __version__ = '0.1.22'
99
+ # Public name -> (module, attribute) for names that need Crawlee. Loaded on
100
+ # first access and cached in the module namespace.
101
+ _LAZY_ATTRS = {
102
+ # Crawlee re-exports
103
+ 'Request': ('crawlee', 'Request'),
104
+ 'RequestLoader': ('crawlee.request_loaders', 'RequestLoader'),
105
+ 'RequestManager': ('crawlee.request_loaders', 'RequestManager'),
106
+ 'RequestManagerTandem': ('crawlee.request_loaders', 'RequestManagerTandem'),
107
+ 'SitemapRequestLoader': ('crawlee.request_loaders', 'SitemapRequestLoader'),
108
+ 'Dataset': ('crawlee.storages', 'Dataset'),
109
+ 'KeyValueStore': ('crawlee.storages', 'KeyValueStore'),
110
+ 'RequestQueue': ('crawlee.storages', 'RequestQueue'),
111
+ 'DatasetItemsListPage': ('crawlee.storage_clients.models', 'DatasetItemsListPage'),
112
+ 'EventListener': ('crawlee.events', 'EventListener'),
113
+ # Storage clients
114
+ 'ApiStorageClient': ('scrapely.storage', 'ApiStorageClient'),
115
+ 'FileSystemStorageClient': ('scrapely.storage', 'FileSystemStorageClient'),
116
+ 'MemoryStorageClient': ('scrapely.storage', 'MemoryStorageClient'),
117
+ 'ScrapelyStorageClient': ('scrapely.storage', 'ScrapelyStorageClient'),
118
+ 'SmartScrapelyStorageClient': ('scrapely.storage', 'SmartScrapelyStorageClient'),
119
+ 'StorageClient': ('scrapely.storage', 'StorageClient'),
120
+ 'set_api_storage': ('scrapely.storage', 'set_api_storage'),
121
+ # Request loaders
122
+ 'ScrapelyRequestList': ('scrapely.request_loaders', 'ScrapelyRequestList'),
123
+ # Events
124
+ **{
125
+ name: ('scrapely.events', name)
126
+ for name in (
127
+ 'AbortingEvent',
128
+ 'ActorEventTypes',
129
+ 'CrawleeEventManager',
130
+ 'DeprecatedEvent',
131
+ 'Event',
132
+ 'EventAbortingData',
133
+ 'EventExitData',
134
+ 'EventManager',
135
+ 'EventMessage',
136
+ 'EventMigratingData',
137
+ 'EventPersistStateData',
138
+ 'EventSystemInfoData',
139
+ 'EventWithoutData',
140
+ 'ExitEvent',
141
+ 'LocalEventManager',
142
+ 'MigratingEvent',
143
+ 'PersistStateEvent',
144
+ 'ScrapelyEventManager',
145
+ 'SystemInfoEvent',
146
+ 'SystemInfoEventData',
147
+ 'UnknownEvent',
148
+ )
149
+ },
150
+ }
151
+
152
+ # Submodules that used to be loaded (and so bound as attributes) by this
153
+ # package's eager imports; ``scrapely.events`` etc. keep working without an
154
+ # explicit ``import scrapely.events``.
155
+ _LAZY_SUBMODULES = frozenset({
156
+ 'events',
157
+ 'log',
158
+ 'request_loaders',
159
+ 'storage',
160
+ 'storage_clients',
161
+ 'storages',
162
+ })
163
+
164
+
165
+ def __getattr__(name: str) -> Any:
166
+ target = _LAZY_ATTRS.get(name)
167
+ if target is not None:
168
+ import importlib
169
+
170
+ value = getattr(importlib.import_module(target[0]), target[1])
171
+ globals()[name] = value
172
+ return value
173
+ if name in _LAZY_SUBMODULES:
174
+ import importlib
175
+
176
+ return importlib.import_module(f'{__name__}.{name}')
177
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
178
+
179
+
180
+ def __dir__() -> list:
181
+ return sorted(set(globals()) | set(_LAZY_ATTRS) | _LAZY_SUBMODULES)
182
+
183
+
184
+ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain imports.
185
+ from crawlee import Request
186
+ from crawlee.events import EventListener
187
+ from crawlee.request_loaders import (
188
+ RequestLoader,
189
+ RequestManager,
190
+ RequestManagerTandem,
191
+ SitemapRequestLoader,
192
+ )
193
+ from crawlee.storage_clients.models import DatasetItemsListPage
194
+ from crawlee.storages import Dataset, KeyValueStore, RequestQueue
195
+
196
+ from .events import (
197
+ AbortingEvent,
198
+ ActorEventTypes,
199
+ CrawleeEventManager,
200
+ DeprecatedEvent,
201
+ Event,
202
+ EventAbortingData,
203
+ EventExitData,
204
+ EventManager,
205
+ EventMessage,
206
+ EventMigratingData,
207
+ EventPersistStateData,
208
+ EventSystemInfoData,
209
+ EventWithoutData,
210
+ ExitEvent,
211
+ LocalEventManager,
212
+ MigratingEvent,
213
+ PersistStateEvent,
214
+ ScrapelyEventManager,
215
+ SystemInfoEvent,
216
+ SystemInfoEventData,
217
+ UnknownEvent,
218
+ )
219
+ from .request_loaders import ScrapelyRequestList
220
+ from .storage import (
221
+ ApiStorageClient,
222
+ FileSystemStorageClient,
223
+ MemoryStorageClient,
224
+ ScrapelyStorageClient,
225
+ SmartScrapelyStorageClient,
226
+ StorageClient,
227
+ set_api_storage,
228
+ )
229
+
230
+ __version__ = '0.1.23'
136
231
  __all__ = [
137
232
  # Core
138
233
  'Actor',
scrapely/actor.py CHANGED
@@ -20,10 +20,6 @@ from datetime import datetime, timedelta
20
20
  from decimal import Decimal
21
21
  from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union, cast
22
22
 
23
- from crawlee.configuration import Configuration as CrawleeConfiguration
24
- from crawlee.storage_clients._base import StorageClient
25
- from crawlee.storages import Dataset, KeyValueStore, RequestQueue
26
-
27
23
  from .charging import (
28
24
  ChargingManager,
29
25
  is_default_dataset_item_event,
@@ -34,23 +30,10 @@ from .charging import (
34
30
  from .configuration import Configuration
35
31
  from .crypto import decrypt_secret_inputs
36
32
  from .proxy_configuration import with_proxy_key_aliases
37
- from crawlee.events import EventManager
38
-
39
- from .events import Event, LocalEventManager, ScrapelyEventManager
40
33
  from .charging_client import create_charging_client
41
34
  from .errors import ForbiddenError
42
35
  from .storage.api_client import ApiClient, ApiClientError
43
36
  from .storage.client import is_storage_uuid
44
- from .storage._metadata import StorageType, resolve_alias_from_storages_json
45
- from .storage.patch_storage import (
46
- disconnect_storage,
47
- get_api_storage_factory,
48
- get_smart_storage_client,
49
- set_api_storage,
50
- set_storage_client,
51
- )
52
- from .storage.scrapely_storage_client import ScrapelyStorageClient
53
- from .storage.smart_scrapely_storage_client import SmartScrapelyStorageClient
54
37
  from .types import (
55
38
  AbortOptions,
56
39
  ActorRun,
@@ -66,17 +49,74 @@ from .types import (
66
49
  StartOptions,
67
50
  WebhookOptions,
68
51
  )
69
- from .log import _configure_logging
70
52
  from .webhook import Webhook
71
53
 
72
54
  if TYPE_CHECKING:
55
+ from crawlee.events import EventManager
56
+ from crawlee.storage_clients._base import StorageClient
57
+ from crawlee.storages import Dataset, KeyValueStore, RequestQueue
73
58
  from scrapely_client import ApifyClientAsync
74
59
 
60
+ from .events import Event
61
+ from .storage._metadata import StorageType
62
+ from .storage.scrapely_storage_client import ScrapelyStorageClient
63
+ from .storage.smart_scrapely_storage_client import SmartScrapelyStorageClient
64
+
75
65
  from .browser_configuration import BrowserConfiguration
76
66
  from .proxy_configuration import ProxyConfiguration
77
67
 
78
68
  logger = logging.getLogger(__name__)
79
69
 
70
+ # Names that need Crawlee, resolved on first use instead of at import: importing
71
+ # Crawlee costs ~1 s per run under gVisor. They stay module attributes
72
+ # (``scrapely.actor.Dataset`` works and can be monkeypatched): ``_lazy`` returns
73
+ # the module-global value when one is set, else imports and caches it.
74
+ _LAZY_ATTRS: Dict[str, Tuple[str, str]] = {
75
+ 'CrawleeConfiguration': ('crawlee.configuration', 'Configuration'),
76
+ 'StorageClient': ('crawlee.storage_clients._base', 'StorageClient'),
77
+ 'Dataset': ('crawlee.storages', 'Dataset'),
78
+ 'KeyValueStore': ('crawlee.storages', 'KeyValueStore'),
79
+ 'RequestQueue': ('crawlee.storages', 'RequestQueue'),
80
+ 'EventManager': ('crawlee.events', 'EventManager'),
81
+ 'Event': ('scrapely.events', 'Event'),
82
+ 'LocalEventManager': ('scrapely.events', 'LocalEventManager'),
83
+ 'ScrapelyEventManager': ('scrapely.events', 'ScrapelyEventManager'),
84
+ 'StorageType': ('scrapely.storage._metadata', 'StorageType'),
85
+ 'resolve_alias_from_storages_json': ('scrapely.storage._metadata', 'resolve_alias_from_storages_json'),
86
+ 'disconnect_storage': ('scrapely.storage.patch_storage', 'disconnect_storage'),
87
+ 'get_api_storage_factory': ('scrapely.storage.patch_storage', 'get_api_storage_factory'),
88
+ 'get_smart_storage_client': ('scrapely.storage.patch_storage', 'get_smart_storage_client'),
89
+ 'set_api_storage': ('scrapely.storage.patch_storage', 'set_api_storage'),
90
+ 'set_storage_client': ('scrapely.storage.patch_storage', 'set_storage_client'),
91
+ 'ScrapelyStorageClient': ('scrapely.storage.scrapely_storage_client', 'ScrapelyStorageClient'),
92
+ 'SmartScrapelyStorageClient': (
93
+ 'scrapely.storage.smart_scrapely_storage_client',
94
+ 'SmartScrapelyStorageClient',
95
+ ),
96
+ '_configure_logging': ('scrapely.log', '_configure_logging'),
97
+ }
98
+
99
+
100
+ def _lazy(name: str) -> Any:
101
+ """Return a Crawlee-dependent module attribute, importing it on first use."""
102
+ value = globals().get(name, _MISSING)
103
+ if value is _MISSING:
104
+ import importlib
105
+
106
+ module_name, attr = _LAZY_ATTRS[name]
107
+ value = getattr(importlib.import_module(module_name), attr)
108
+ globals()[name] = value
109
+ return value
110
+
111
+
112
+ _MISSING = object()
113
+
114
+
115
+ def __getattr__(name: str) -> Any:
116
+ if name in _LAZY_ATTRS:
117
+ return _lazy(name)
118
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
119
+
80
120
  # Terminal run statuses used by the run-control polling loops (call/call_task).
81
121
  _RUN_TERMINAL_STATUSES = frozenset({'SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'})
82
122
 
@@ -324,7 +364,7 @@ class Actor(metaclass=_ActorMeta):
324
364
  return
325
365
 
326
366
  if options is None or options.configure_logging:
327
- _configure_logging()
367
+ _lazy('_configure_logging')()
328
368
 
329
369
  # Do NOT set _initialized until everything succeeds (failed init must be retryable).
330
370
  cls._config = Configuration.get_global_config()
@@ -338,7 +378,7 @@ class Actor(metaclass=_ActorMeta):
338
378
  for key, value in options.configuration.items():
339
379
  cls._config.set(key, value)
340
380
 
341
- crawlee_config = CrawleeConfiguration.get_global_configuration()
381
+ crawlee_config = _lazy('CrawleeConfiguration').get_global_configuration()
342
382
  if cls._config.get('is_at_home'):
343
383
  crawlee_config.purge_on_start = False
344
384
 
@@ -361,9 +401,9 @@ class Actor(metaclass=_ActorMeta):
361
401
  '[Scrapely] Initializing API storage for run %s',
362
402
  storage_opt['run_id'],
363
403
  )
364
- smart = set_api_storage(storage_opt)
365
- elif isinstance(storage_opt, StorageClient):
366
- smart = set_storage_client(storage_opt)
404
+ smart = _lazy('set_api_storage')(storage_opt)
405
+ elif isinstance(storage_opt, _lazy('StorageClient')):
406
+ smart = _lazy('set_storage_client')(storage_opt)
367
407
  else:
368
408
  raise TypeError(
369
409
  'Actor.init() storage option must be ApiStorageConfig or StorageClient'
@@ -375,7 +415,7 @@ class Actor(metaclass=_ActorMeta):
375
415
 
376
416
  if api_url and token and run_id:
377
417
  logger.info('[Scrapely] Initializing API storage for run %s', run_id)
378
- smart = set_api_storage({
418
+ smart = _lazy('set_api_storage')({
379
419
  'api_url': api_url,
380
420
  'token': token,
381
421
  'run_id': run_id,
@@ -383,7 +423,7 @@ class Actor(metaclass=_ActorMeta):
383
423
  elif not cls._config.get('is_at_home'):
384
424
  # Local development without credentials: Smart → FS (Apify docs shape)
385
425
  logger.info('[Scrapely] Initializing local file-system storage')
386
- smart = set_api_storage()
426
+ smart = _lazy('set_api_storage')()
387
427
  else:
388
428
  raise RuntimeError(
389
429
  'Missing required configuration for secure storage. '
@@ -392,8 +432,12 @@ class Actor(metaclass=_ActorMeta):
392
432
  'or pass storage in Actor.init().'
393
433
  )
394
434
 
395
- cls._smart_storage = smart if isinstance(smart, SmartScrapelyStorageClient) else get_smart_storage_client()
396
- factory = get_api_storage_factory()
435
+ cls._smart_storage = (
436
+ smart
437
+ if isinstance(smart, _lazy('SmartScrapelyStorageClient'))
438
+ else _lazy('get_smart_storage_client')()
439
+ )
440
+ factory = _lazy('get_api_storage_factory')()
397
441
  cls._scrapely_storage = factory
398
442
  if factory is not None and factory.has_credentials():
399
443
  cls._client = factory.api_client
@@ -408,12 +452,12 @@ class Actor(metaclass=_ActorMeta):
408
452
 
409
453
  event_manager: EventManager
410
454
  if ws_url:
411
- event_manager = ScrapelyEventManager(
455
+ event_manager = _lazy('ScrapelyEventManager')(
412
456
  configuration=cls._config,
413
457
  persist_state_interval=persist_interval,
414
458
  )
415
459
  else:
416
- event_manager = LocalEventManager(
460
+ event_manager = _lazy('LocalEventManager')(
417
461
  system_info_interval=timedelta(seconds=1),
418
462
  persist_state_interval=persist_interval,
419
463
  )
@@ -544,7 +588,7 @@ class Actor(metaclass=_ActorMeta):
544
588
  cls._default_rq = None
545
589
  cls._cached_input = None
546
590
  try:
547
- disconnect_storage()
591
+ _lazy('disconnect_storage')()
548
592
  except BaseException as e:
549
593
  cleanup_error = cleanup_error or e
550
594
  logger.warning('disconnect_storage failed during shutdown: %s', e)
@@ -836,7 +880,7 @@ class Actor(metaclass=_ActorMeta):
836
880
 
837
881
  target = cls._default_dataset
838
882
  if dataset is not None:
839
- target = dataset if isinstance(dataset, Dataset) else await cls.open_dataset(name=dataset)
883
+ target = dataset if isinstance(dataset, _lazy('Dataset')) else await cls.open_dataset(name=dataset)
840
884
  is_default = target is cls._default_dataset or (
841
885
  getattr(target, 'id', None) is not None and target.id == getattr(cls._default_dataset, 'id', None)
842
886
  )
@@ -902,7 +946,7 @@ class Actor(metaclass=_ActorMeta):
902
946
 
903
947
  if alias is not None:
904
948
  storages_json = cls._env_config().get('actor_storages_json')
905
- resolved = resolve_alias_from_storages_json(alias, storage_type, storages_json)
949
+ resolved = _lazy('resolve_alias_from_storages_json')(alias, storage_type, storages_json)
906
950
  if resolved is not None:
907
951
  id = resolved
908
952
  alias = None
@@ -915,12 +959,12 @@ class Actor(metaclass=_ActorMeta):
915
959
 
916
960
  @classmethod
917
961
  def _storage_client_for_open(cls, *, force_cloud: bool = False) -> Optional[StorageClient]:
918
- smart = cls._smart_storage or get_smart_storage_client()
962
+ smart = cls._smart_storage or _lazy('get_smart_storage_client')()
919
963
  if smart is not None:
920
964
  return smart.get_suitable_storage_client(force_cloud=force_cloud)
921
965
 
922
966
  if force_cloud:
923
- cloud = get_api_storage_factory()
967
+ cloud = _lazy('get_api_storage_factory')()
924
968
  if cloud is None:
925
969
  # Build from config like TS _buildStorageOpenOptions
926
970
  config = cls.get_config()
@@ -928,7 +972,7 @@ class Actor(metaclass=_ActorMeta):
928
972
  token = config.get('token')
929
973
  run_id = config.get('actor_run_id')
930
974
  if api_url and token and run_id:
931
- return ScrapelyStorageClient(api_url=api_url, token=token, run_id=run_id)
975
+ return _lazy('ScrapelyStorageClient')(api_url=api_url, token=token, run_id=run_id)
932
976
  raise RuntimeError('Cloud storage client is not configured')
933
977
  return cloud
934
978
  return None
@@ -958,10 +1002,10 @@ class Actor(metaclass=_ActorMeta):
958
1002
 
959
1003
  if open_args['id'] is None and open_args['name'] is None and open_args['alias'] is None:
960
1004
  if cls._default_dataset is None:
961
- cls._default_dataset = await Dataset.open(storage_client=storage_client)
1005
+ cls._default_dataset = await _lazy('Dataset').open(storage_client=storage_client)
962
1006
  return cls._default_dataset
963
1007
 
964
- return await Dataset.open(
1008
+ return await _lazy('Dataset').open(
965
1009
  id=open_args['id'],
966
1010
  name=open_args['name'],
967
1011
  alias=open_args['alias'],
@@ -993,10 +1037,10 @@ class Actor(metaclass=_ActorMeta):
993
1037
 
994
1038
  if open_args['id'] is None and open_args['name'] is None and open_args['alias'] is None:
995
1039
  if cls._default_kvs is None:
996
- cls._default_kvs = await KeyValueStore.open(storage_client=storage_client)
1040
+ cls._default_kvs = await _lazy('KeyValueStore').open(storage_client=storage_client)
997
1041
  return cls._default_kvs
998
1042
 
999
- return await KeyValueStore.open(
1043
+ return await _lazy('KeyValueStore').open(
1000
1044
  id=open_args['id'],
1001
1045
  name=open_args['name'],
1002
1046
  alias=open_args['alias'],
@@ -1028,10 +1072,10 @@ class Actor(metaclass=_ActorMeta):
1028
1072
 
1029
1073
  if open_args['id'] is None and open_args['name'] is None and open_args['alias'] is None:
1030
1074
  if cls._default_rq is None:
1031
- cls._default_rq = await RequestQueue.open(storage_client=storage_client)
1075
+ cls._default_rq = await _lazy('RequestQueue').open(storage_client=storage_client)
1032
1076
  return cls._default_rq
1033
1077
 
1034
- return await RequestQueue.open(
1078
+ return await _lazy('RequestQueue').open(
1035
1079
  id=open_args['id'],
1036
1080
  name=open_args['name'],
1037
1081
  alias=open_args['alias'],
@@ -1710,7 +1754,7 @@ class Actor(metaclass=_ActorMeta):
1710
1754
  if cls._event_manager:
1711
1755
  from crawlee.events import EventPersistStateData
1712
1756
  cls._event_manager.emit(
1713
- event=Event.PERSIST_STATE,
1757
+ event=_lazy('Event').PERSIST_STATE,
1714
1758
  event_data=EventPersistStateData(is_migrating=True),
1715
1759
  )
1716
1760
 
@@ -30,6 +30,11 @@ Mirrors the Node SDK ``patch-browser-pool.ts``:
30
30
  gVisor-safe Chromium args under ``SCRAPELY_CONTAINER_RUNTIME=runsc``.
31
31
  - Soft no-op when Playwright/Crawlee browsers are not installed (e.g. HTTP-only
32
32
  actors).
33
+ - Crawlee is never imported by this module (it costs ~1 s under gVisor). The
34
+ Crawlee patches are applied at once when Crawlee's browser / Playwright
35
+ crawler modules are already loaded, otherwise by a post-import hook the
36
+ moment those modules finish loading — before actor code can use the classes.
37
+ The Playwright, Camoufox and pyppeteer patches are applied eagerly on import.
33
38
 
34
39
  Location and proxy handling (the remote browser never takes a proxy):
35
40
 
@@ -63,9 +68,11 @@ from __future__ import annotations
63
68
 
64
69
  import contextvars
65
70
  import functools
71
+ import importlib.abc
66
72
  import logging
67
73
  import os
68
74
  import re
75
+ import sys
69
76
  from typing import Any, List, Optional, Tuple
70
77
  from urllib.parse import SplitResult, parse_qsl, unquote, urlencode, urlsplit, urlunsplit
71
78
 
@@ -459,7 +466,9 @@ def _apply_crawler_patch() -> None:
459
466
  ``new_browser`` by the patched ``BrowserPool._get_new_page``.
460
467
  """
461
468
  try:
462
- from crawlee.crawlers import PlaywrightCrawler
469
+ # The defining module, not the ``crawlee.crawlers`` package: the
470
+ # post-import hook runs this while that package is still initialising.
471
+ from crawlee.crawlers._playwright._playwright_crawler import PlaywrightCrawler
463
472
  except ImportError:
464
473
  return
465
474
 
@@ -865,8 +874,92 @@ def _apply_pyppeteer_patch() -> None:
865
874
  pyppeteer_mod._scrapely_cdp_patched = True
866
875
 
867
876
 
868
- _apply_patch()
869
- _apply_crawler_patch()
877
+ # ─── Crawlee patches without importing Crawlee ───────────────────────────────
878
+ #
879
+ # Importing Crawlee costs ~1 s per run under gVisor, so this module never
880
+ # imports it. The Crawlee patches mutate classes in place, so they only have to
881
+ # run before actor code uses those classes: they are applied immediately when
882
+ # the defining modules are already loaded, otherwise by a post-import hook the
883
+ # moment they finish executing (``import crawlee.browsers``,
884
+ # ``from crawlee.crawlers import PlaywrightCrawler``, a PlaywrightCrawler built
885
+ # by any template...). Behaviour is the same as applying them eagerly.
886
+
887
+ # Module name -> patch functions to run once that module has finished loading.
888
+ # ``crawlee.browsers`` imports BrowserPool, PlaywrightBrowserController and
889
+ # PlaywrightBrowserPlugin in its ``__init__``; any import of one of those
890
+ # submodules runs that ``__init__`` to completion first.
891
+ _CRAWLEE_POST_IMPORT_PATCHES: dict = {
892
+ 'crawlee.browsers': (_apply_patch,),
893
+ 'crawlee.crawlers._playwright._playwright_crawler': (_apply_crawler_patch,),
894
+ }
895
+
896
+
897
+ class _PostImportLoader(importlib.abc.Loader):
898
+ """Delegating loader that runs the Scrapely patches after the module executes."""
899
+
900
+ def __init__(self, loader: Any, patches: tuple) -> None:
901
+ self._loader = loader
902
+ self._patches = patches
903
+
904
+ def create_module(self, spec: Any) -> Any:
905
+ return self._loader.create_module(spec)
906
+
907
+ def exec_module(self, module: Any) -> None:
908
+ # Leave the module looking untouched (resource readers, reload, ...).
909
+ spec = getattr(module, '__spec__', None)
910
+ if spec is not None:
911
+ spec.loader = self._loader
912
+ try:
913
+ module.__loader__ = self._loader
914
+ except Exception:
915
+ pass
916
+ self._loader.exec_module(module)
917
+ for patch in self._patches:
918
+ patch()
919
+
920
+ def __getattr__(self, name: str) -> Any:
921
+ return getattr(self._loader, name)
922
+
923
+
924
+ class _CrawleePostImportFinder(importlib.abc.MetaPathFinder):
925
+ """Meta path finder that hooks the Crawlee modules in ``_CRAWLEE_POST_IMPORT_PATCHES``."""
926
+
927
+ def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> Any:
928
+ patches = _CRAWLEE_POST_IMPORT_PATCHES.get(fullname)
929
+ if patches is None:
930
+ return None
931
+ for finder in sys.meta_path:
932
+ if finder is self or isinstance(finder, _CrawleePostImportFinder):
933
+ continue
934
+ find_spec = getattr(finder, 'find_spec', None)
935
+ if find_spec is None:
936
+ continue
937
+ spec = find_spec(fullname, path, target)
938
+ if spec is not None:
939
+ break
940
+ else:
941
+ return None
942
+ if spec.loader is None or not hasattr(spec.loader, 'exec_module'):
943
+ return spec
944
+ # One-shot: the patches are idempotent, but a module loads only once.
945
+ _CRAWLEE_POST_IMPORT_PATCHES.pop(fullname, None)
946
+ spec.loader = _PostImportLoader(spec.loader, patches)
947
+ return spec
948
+
949
+
950
+ def _install_crawlee_patches() -> None:
951
+ """Apply the Crawlee patches now if Crawlee is loaded, else when it loads."""
952
+ for module_name in list(_CRAWLEE_POST_IMPORT_PATCHES):
953
+ if module_name in sys.modules:
954
+ for patch in _CRAWLEE_POST_IMPORT_PATCHES.pop(module_name):
955
+ patch()
956
+ if _CRAWLEE_POST_IMPORT_PATCHES and not any(
957
+ isinstance(finder, _CrawleePostImportFinder) for finder in sys.meta_path
958
+ ):
959
+ sys.meta_path.insert(0, _CrawleePostImportFinder())
960
+
961
+
962
+ _install_crawlee_patches()
870
963
  _apply_camoufox_patch()
871
964
  _apply_raw_playwright_patch()
872
965
  _apply_pyppeteer_patch()
@@ -1,27 +1,92 @@
1
1
  """Scrapely storage: Crawlee StorageClient backends + HTTP helpers."""
2
2
 
3
- from crawlee import Request
4
- from crawlee.storage_clients import MemoryStorageClient, StorageClient
5
- from crawlee.storages import Dataset, KeyValueStore, RequestQueue
3
+ from typing import TYPE_CHECKING, Any
6
4
 
5
+ # Only the Crawlee-free HTTP helpers load eagerly, so ``Actor`` can use
6
+ # ``scrapely.storage.api_client`` / ``.client`` without importing Crawlee.
7
+ # The Crawlee StorageClient backends load on first attribute access.
7
8
  from .api_client import ApiClient, ApiClientError
8
9
  from .client import ApiStorageClient
9
- from ._file_system import ScrapelyFileSystemStorageClient
10
- from .patch_storage import (
11
- disconnect_storage,
12
- get_api_storage_factory,
13
- get_auto_saved_value,
14
- get_smart_storage_client,
15
- is_api_storage_configured,
16
- persist_autosaved_values,
17
- set_api_storage,
18
- set_storage_client,
19
- )
20
- from .scrapely_storage_client import ScrapelyStorageClient
21
- from .smart_scrapely_storage_client import SmartScrapelyStorageClient
22
-
23
- # Apify-style public alias
24
- FileSystemStorageClient = ScrapelyFileSystemStorageClient
10
+
11
+ _LAZY_ATTRS = {
12
+ 'Request': ('crawlee', 'Request'),
13
+ 'MemoryStorageClient': ('crawlee.storage_clients', 'MemoryStorageClient'),
14
+ 'StorageClient': ('crawlee.storage_clients', 'StorageClient'),
15
+ 'Dataset': ('crawlee.storages', 'Dataset'),
16
+ 'KeyValueStore': ('crawlee.storages', 'KeyValueStore'),
17
+ 'RequestQueue': ('crawlee.storages', 'RequestQueue'),
18
+ 'ScrapelyFileSystemStorageClient': ('scrapely.storage._file_system', 'ScrapelyFileSystemStorageClient'),
19
+ # Apify-style public alias
20
+ 'FileSystemStorageClient': ('scrapely.storage._file_system', 'ScrapelyFileSystemStorageClient'),
21
+ 'ScrapelyStorageClient': ('scrapely.storage.scrapely_storage_client', 'ScrapelyStorageClient'),
22
+ 'SmartScrapelyStorageClient': ('scrapely.storage.smart_scrapely_storage_client', 'SmartScrapelyStorageClient'),
23
+ **{
24
+ name: ('scrapely.storage.patch_storage', name)
25
+ for name in (
26
+ 'disconnect_storage',
27
+ 'get_api_storage_factory',
28
+ 'get_auto_saved_value',
29
+ 'get_smart_storage_client',
30
+ 'is_api_storage_configured',
31
+ 'persist_autosaved_values',
32
+ 'set_api_storage',
33
+ 'set_storage_client',
34
+ )
35
+ },
36
+ }
37
+
38
+ # Submodules that used to be bound as attributes by this package's eager imports.
39
+ _LAZY_SUBMODULES = frozenset({
40
+ '_file_system',
41
+ '_metadata',
42
+ 'patch_storage',
43
+ 'scrapely_dataset_client',
44
+ 'scrapely_kvs_client',
45
+ 'scrapely_rq_client',
46
+ 'scrapely_storage_client',
47
+ 'smart_scrapely_storage_client',
48
+ 'utils',
49
+ })
50
+
51
+
52
+ def __getattr__(name: str) -> Any:
53
+ target = _LAZY_ATTRS.get(name)
54
+ if target is not None:
55
+ import importlib
56
+
57
+ value = getattr(importlib.import_module(target[0]), target[1])
58
+ globals()[name] = value
59
+ return value
60
+ if name in _LAZY_SUBMODULES:
61
+ import importlib
62
+
63
+ return importlib.import_module(f'{__name__}.{name}')
64
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
65
+
66
+
67
+ def __dir__() -> list:
68
+ return sorted(set(globals()) | set(_LAZY_ATTRS) | _LAZY_SUBMODULES)
69
+
70
+
71
+ if TYPE_CHECKING:
72
+ from crawlee import Request
73
+ from crawlee.storage_clients import MemoryStorageClient, StorageClient
74
+ from crawlee.storages import Dataset, KeyValueStore, RequestQueue
75
+
76
+ from ._file_system import ScrapelyFileSystemStorageClient
77
+ from ._file_system import ScrapelyFileSystemStorageClient as FileSystemStorageClient
78
+ from .patch_storage import (
79
+ disconnect_storage,
80
+ get_api_storage_factory,
81
+ get_auto_saved_value,
82
+ get_smart_storage_client,
83
+ is_api_storage_configured,
84
+ persist_autosaved_values,
85
+ set_api_storage,
86
+ set_storage_client,
87
+ )
88
+ from .scrapely_storage_client import ScrapelyStorageClient
89
+ from .smart_scrapely_storage_client import SmartScrapelyStorageClient
25
90
 
26
91
  __all__ = [
27
92
  'ApiClient',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.22
3
+ Version: 0.1.23
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
@@ -1,6 +1,6 @@
1
- scrapely/__init__.py,sha256=cxlsfPD_FlaCCerWYImS5rGRnKxE9INU61hDl3C04Y0,5746
1
+ scrapely/__init__.py,sha256=urKlJYfwafwGuUNCaDEPM0wK5H_krI8jYNKys8dsh9U,9661
2
2
  scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
3
- scrapely/actor.py,sha256=yLy05XzoFwslw0D4aSM38rDIGd81JvPqpngY467hWxQ,82969
3
+ scrapely/actor.py,sha256=fNxlB2KxattbBh6aMo1p_ALrDQ3GdbJa03w_6DgIkjs,85310
4
4
  scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
5
5
  scrapely/cdp_retry.py,sha256=hdTJEAIKhApM7IOk1DrQEl8NoflSJSmqd4RbNJ2IsgI,8540
6
6
  scrapely/charging.py,sha256=ns-kuUJtl8hBI4-3iBisC62BggJG67yYOa1yVVItoUI,83800
@@ -8,7 +8,7 @@ scrapely/charging_client.py,sha256=-4q-Ww7gu9EZXpM9QIn1Vd4vwAXmgL_WEKqaQIx3ViA,3
8
8
  scrapely/configuration.py,sha256=Of-lKm7hoPGUQO8d08kV7fAy2co9QTfb8Of_8ds5PSA,17028
9
9
  scrapely/errors.py,sha256=DCAlVvE3jToZuPEG2HVDL7i8D8hLboomQ5JpSOcsXoQ,4107
10
10
  scrapely/log.py,sha256=iVKoQdMLQXEWz1STzbSXrNUxsS_FNwiIdqoEg7CzeqU,1440
11
- scrapely/patch_browser_pool.py,sha256=qZstdQ1rFw8kGjzB9fm3GXco8Jt40eklTw1Czf_vh-8,38018
11
+ scrapely/patch_browser_pool.py,sha256=R-1QDKa-ZvfdQ9wgyA8JCtmQqD9v7dOsaCZnSUoyf7U,42214
12
12
  scrapely/pricing_types.py,sha256=J6wCddZd-U1MYeOtaaEdeHSsp1HBgiQLfvSInBOvhIo,5305
13
13
  scrapely/proxy_configuration.py,sha256=4passyiUBfBsuzaBDyf5003rY0ayLYwwSnev0FaxCs0,38125
14
14
  scrapely/py.typed,sha256=XtWlywqUjc6x_M2gAXlDtYXbAsYkCjKZEjFyJvaHvm4,105
@@ -22,7 +22,7 @@ scrapely/events/_types.py,sha256=XslMIEAQOe7othqevnxO86c7i7AysbvDLHH0lep64QU,599
22
22
  scrapely/events/event_manager.py,sha256=9x3VqNy1gBi5tNQRv_1KptCRJQ9QnPwCDLT7JdlvjIM,12774
23
23
  scrapely/request_loaders/__init__.py,sha256=aNgr4pRt3MziUbU6UQ_bjTT3moMNvvyUsPeCJyMQcwU,196
24
24
  scrapely/request_loaders/_scrapely_request_list.py,sha256=vguj-EvBjvrGY5_vf2fGXnJBfnhlPeRjdkjPFaxUA7g,7628
25
- scrapely/storage/__init__.py,sha256=diAhxEvOXjc3YVhZvzkO3MttJUXQWJ5ogxstiApsh7U,1405
25
+ scrapely/storage/__init__.py,sha256=GWVzHJPlcsZp0bDCZwvYFwQD-JXSkoEYGLOFC1siLYw,3852
26
26
  scrapely/storage/_metadata.py,sha256=WWawBnVN0b1iK2K-SlxZXUc6czb5wXWf8iC_0tzkgAQ,4764
27
27
  scrapely/storage/api_client.py,sha256=uhIU5LnOmW0x6MOMt1H7iWlvs-b55Sn4zVCgAnynTug,22900
28
28
  scrapely/storage/client.py,sha256=dfTD3424kb0AAFdFU5F6HMcS2XaKcJ1ACiViYniKDXg,22562
@@ -39,10 +39,10 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=4Ipgbh4qEqAkwQJ9
39
39
  scrapely/storage/_file_system/_storage_client.py,sha256=aaGJ4P0QAtIgEMl6yHpuobeWFD5M4rMC5HwhOOvuGuo,1794
40
40
  scrapely/storage_clients/__init__.py,sha256=cs1YX7JV2_GKBXeiiJgkQFali08b5_y_jH6Yw7iROAA,656
41
41
  scrapely/storages/__init__.py,sha256=4PAELhJNZ3exJ7Uouicn-bCQ58NafKjhLBixQ8ATQc4,173
42
- scrapely_python-0.1.22.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
43
- scrapely_python-0.1.22.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
44
- scrapely_python-0.1.22.dist-info/licenses/NOTICE,sha256=DEsNrw6UdSIqdYYqMWVFgJnyt6jUivTD7iFU6YM_e1A,1122
45
- scrapely_python-0.1.22.dist-info/METADATA,sha256=K-JZZELeDs7kIXmXodCXXVF3YCvozeqESmkQ5StszVw,3487
46
- scrapely_python-0.1.22.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
47
- scrapely_python-0.1.22.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
48
- scrapely_python-0.1.22.dist-info/RECORD,,
42
+ scrapely_python-0.1.23.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
43
+ scrapely_python-0.1.23.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
44
+ scrapely_python-0.1.23.dist-info/licenses/NOTICE,sha256=DEsNrw6UdSIqdYYqMWVFgJnyt6jUivTD7iFU6YM_e1A,1122
45
+ scrapely_python-0.1.23.dist-info/METADATA,sha256=AY2FmyNKFInqRuBqZ2HnLSUVjz-UZ4bKeRTV5OsgPTU,3487
46
+ scrapely_python-0.1.23.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
47
+ scrapely_python-0.1.23.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
48
+ scrapely_python-0.1.23.dist-info/RECORD,,