scrapely-python 0.1.42__tar.gz → 0.1.43__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 (84) hide show
  1. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/CHANGELOG.md +11 -0
  2. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/PKG-INFO +1 -1
  3. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/pyproject.toml +1 -1
  4. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/__init__.py +182 -84
  5. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/charging_client.py +21 -5
  6. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/patch_browser_pool.py +174 -72
  7. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  8. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/LICENSE +0 -0
  9. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/LICENSE-APACHE +0 -0
  10. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/MANIFEST.in +0 -0
  11. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/NOTICE +0 -0
  12. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/README.md +0 -0
  13. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/setup.cfg +0 -0
  14. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/_browser_locations.py +0 -0
  15. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/actor.py +0 -0
  16. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/browser_configuration.py +0 -0
  17. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/cdp_retry.py +0 -0
  18. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/charging.py +0 -0
  19. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/configuration.py +0 -0
  20. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/crypto/__init__.py +0 -0
  21. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/crypto/_secrets.py +0 -0
  22. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/errors.py +0 -0
  23. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/events/__init__.py +0 -0
  24. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/events/_types.py +0 -0
  25. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/events/event_manager.py +0 -0
  26. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/__init__.py +0 -0
  27. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_capture.py +0 -0
  28. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_client.py +0 -0
  29. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_compat.py +0 -0
  30. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_detect.py +0 -0
  31. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_headers.py +0 -0
  32. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_options.py +0 -0
  33. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_policy.py +0 -0
  34. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_profiles.py +0 -0
  35. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_response.py +0 -0
  36. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_stats.py +0 -0
  37. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/_transport.py +0 -0
  38. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/http/profiles.json +0 -0
  39. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/log.py +0 -0
  40. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/pricing_types.py +0 -0
  41. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/proxy_configuration.py +0 -0
  42. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/py.typed +0 -0
  43. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/request_loaders/__init__.py +0 -0
  44. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  45. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/__init__.py +0 -0
  46. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/_actor_runner.py +0 -0
  47. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/_async_thread.py +0 -0
  48. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/_logging_config.py +0 -0
  49. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/_serialization.py +0 -0
  50. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/_warnings.py +0 -0
  51. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/extensions/__init__.py +0 -0
  52. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
  53. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
  54. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
  55. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
  56. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
  57. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
  58. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/requests.py +0 -0
  59. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/scheduler.py +0 -0
  60. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/scrapy/utils.py +0 -0
  61. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/solver.py +0 -0
  62. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/__init__.py +0 -0
  63. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/_file_system/__init__.py +0 -0
  64. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  65. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  66. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/_metadata.py +0 -0
  67. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/api_client.py +0 -0
  68. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/client.py +0 -0
  69. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/patch_storage.py +0 -0
  70. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  71. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  72. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  73. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  74. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  75. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/types.py +0 -0
  76. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage/utils.py +0 -0
  77. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storage_clients/__init__.py +0 -0
  78. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/storages/__init__.py +0 -0
  79. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/types.py +0 -0
  80. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely/webhook.py +0 -0
  81. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  82. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  83. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/requires.txt +0 -0
  84. {scrapely_python-0.1.42 → scrapely_python-0.1.43}/src/scrapely_python.egg-info/top_level.txt +0 -0
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.43] - 2026-11-05
8
+
9
+ - **Python actor processes start without loading the SDK.** The base images import `scrapely.patch_browser_pool` (from `sitecustomize`) in every Python process. Until now that loaded the whole SDK at each interpreter start: `scrapely.actor`, charging, the `scrapely_client` models and Playwright. On `actor-python:3.11-0.1.42`, `python -c pass` took 0.89-0.95 s of CPU, which is 4.2 s of wall time at a 0.25-core quota. It now takes 0.016 s. Each import below gets the same start-up cut:
10
+ - `import scrapely` is lazy (PEP 562). Every public name and submodule attribute resolves on first use, so every `from scrapely import X` and `scrapely.X` that worked before still works.
11
+ - `scrapely_client` loads only when an API client is created (`Actor.apify_client`, `create_charging_client`). `USES_PUSH_DATA_INTERCEPTION` and `push_data_charging_context` are still the client's own objects.
12
+ - **Browser patches are applied through an import hook.** `scrapely.patch_browser_pool` imports no browser library any more; it imports builtin modules only. Each patch now runs the moment its library finishes loading, before the import that loaded it returns. That covers the Crawlee BrowserPool / PlaywrightBrowserPlugin / PlaywrightCrawler, raw Playwright (`async_playwright` and `sync_playwright`, each when its module loads), Camoufox `AsyncNewBrowser` and pyppeteer `launch`. The patches behave exactly as before. The hook also:
13
+ - survives `importlib.util.find_spec()` availability checks and a failed first import;
14
+ - logs a warning when a patch fails instead of breaking the actor's import.
15
+ - **Start-up measured on staging** (fresh builds of the same commit on the same base, old SDK vs 0.1.43; "boot" = container started to the SDK's "Initializing API storage" line; paired median): web-scraper -0.74 s, capterra-scraper (raw Playwright over the remote browser) -0.89 s, contact-info-scraper (512 MB) -0.51 s. All 88 scenario runs passed on both sides. Locally at 1 CPU, a full `from apify import Actor` + `Actor.init` + `push_data` + exit went from 1.19-1.36 s to 0.92-1.02 s of CPU.
16
+ - Base images (released with this version): `sitecustomize` no longer imports `pathlib` (~70 ms per start), and the `apify` shim resolves names on first use instead of `from scrapely import *`.
17
+
7
18
  ## [0.1.42] - 2026-11-02
8
19
 
9
20
  - **Sessions that keep their identity: `http.session(keep_identity=True)`.** A pinned `scrapely.http` session used to retire its identity on the first block (a 403, a challenge page, a network error). The session then moved to a new exit, a new profile and an empty cookie jar. That breaks every challenge solve: the challenge page, the solver's script and submission requests and the page fetched again must all share one identity. With `keep_identity=True`:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.42
3
+ Version: 0.1.43
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <hello@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.42"
7
+ version = "0.1.43"
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"
@@ -10,95 +10,114 @@ Usage:
10
10
  await Actor.push_data({'url': actor_input['url']})
11
11
  """
12
12
 
13
- from typing import TYPE_CHECKING, Any
13
+ from __future__ import annotations
14
+
15
+ # ``typing`` is not imported at run time (see below): this package loads at
16
+ # every interpreter start in actor images.
17
+ TYPE_CHECKING = False
18
+ if TYPE_CHECKING:
19
+ from typing import Any
14
20
 
15
21
  # Side-effect: patch browser launches (Crawlee BrowserPool, PlaywrightCrawler,
16
22
  # raw Playwright, Camoufox, pyppeteer) to use the remote CDP browser (CDP_URL).
17
23
  # Must run on package import so `from scrapely import Actor` enables the patch
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.
24
+ # before any browser is launched (Node SDK parity). It imports none of those
25
+ # libraries: each patch is applied the moment its library finishes loading.
20
26
  from .patch_browser_pool import set_cdp_url # noqa: F401
21
27
 
22
- # Crawlee is NOT imported here: it costs ~1 s per run on the platform 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.
27
- from .actor import Actor
28
- from .configuration import Configuration
29
- from .charging_client import (
30
- USES_PUSH_DATA_INTERCEPTION,
31
- create_charging_client,
32
- push_data_charging_context,
33
- )
34
- from .proxy_configuration import ProxyConfiguration
35
- from .browser_configuration import BrowserConfiguration
36
- from .charging import (
37
- ChargingManager,
38
- ChargeUncertainError,
39
- ChargeRejectedError,
40
- has_default_dataset_item_event_in_pricing,
41
- is_default_dataset_item_event,
42
- merge_charge_results,
43
- push_data_and_charge,
44
- resolve_default_dataset_item_event_name,
45
- APIFY_DEFAULT_DATASET_ITEM_EVENT,
46
- SCRAPELY_DEFAULT_DATASET_ITEM_EVENT,
47
- DEFAULT_DATASET_ITEM_EVENT,
48
- )
49
-
50
- from .types import (
51
- ActorInput,
52
- ActorPermissionLevel,
53
- ActorRun,
54
- ApifyEnv,
55
- AbortOptions,
56
- CallOptions,
57
- ExitOptions,
58
- InitOptions,
59
- MetamorphOptions,
60
- OpenStorageOptions,
61
- ProxyInfo,
62
- RebootOptions,
63
- RecordOptions,
64
- SetStatusMessageOptions,
65
- StartOptions,
66
- WebhookOptions,
67
- )
28
+ # Nothing else is imported here. The base images' sitecustomize imports
29
+ # ``scrapely.patch_browser_pool`` (and so this package) in EVERY Python process
30
+ # of an actor image; loading the whole SDK there cost ~0.75 s per interpreter
31
+ # start at 1 CPU (5.7 s at 0.25 CPU). Every public name is resolved on first
32
+ # attribute access by ``__getattr__`` below and cached in the module namespace,
33
+ # so every ``from scrapely import X`` that worked before still works.
68
34
 
69
- from .webhook import (
70
- Webhook,
71
- WebhookEventType,
72
- )
73
- from .pricing_types import (
74
- PricingModel,
75
- FreeActorPricingInfo,
76
- FlatPricePerMonthActorPricingInfo,
77
- PricePerDatasetItemActorPricingInfo,
78
- PayPerEventActorPricingInfo,
79
- PricingPerEvent,
80
- ActorPricingInfo,
81
- ActorChargeEvent,
82
- ChargeResult,
83
- ActorPricingInfoResult,
84
- )
85
- from .errors import (
86
- ScrapelyClientError,
87
- ScrapelyApiError,
88
- InvalidResponseBodyError,
89
- InvalidRequestError,
90
- UnauthorizedError,
91
- ForbiddenError,
92
- NotFoundError,
93
- ConflictError,
94
- RateLimitError,
95
- ServerError,
96
- raise_for_status,
97
- )
98
-
99
- # Public name -> (module, attribute) for names that need Crawlee. Loaded on
100
- # first access and cached in the module namespace.
35
+ # Public name -> (module, attribute). Loaded on first access.
101
36
  _LAZY_ATTRS = {
37
+ 'Actor': ('scrapely.actor', 'Actor'),
38
+ 'Configuration': ('scrapely.configuration', 'Configuration'),
39
+ 'ProxyConfiguration': ('scrapely.proxy_configuration', 'ProxyConfiguration'),
40
+ 'BrowserConfiguration': ('scrapely.browser_configuration', 'BrowserConfiguration'),
41
+ # Charging client
42
+ **{
43
+ name: ('scrapely.charging_client', name)
44
+ for name in ('USES_PUSH_DATA_INTERCEPTION', 'create_charging_client', 'push_data_charging_context')
45
+ },
46
+ # Charging
47
+ **{
48
+ name: ('scrapely.charging', name)
49
+ for name in (
50
+ 'ChargingManager',
51
+ 'ChargeUncertainError',
52
+ 'ChargeRejectedError',
53
+ 'has_default_dataset_item_event_in_pricing',
54
+ 'is_default_dataset_item_event',
55
+ 'merge_charge_results',
56
+ 'push_data_and_charge',
57
+ 'resolve_default_dataset_item_event_name',
58
+ 'APIFY_DEFAULT_DATASET_ITEM_EVENT',
59
+ 'SCRAPELY_DEFAULT_DATASET_ITEM_EVENT',
60
+ 'DEFAULT_DATASET_ITEM_EVENT',
61
+ )
62
+ },
63
+ # Types
64
+ **{
65
+ name: ('scrapely.types', name)
66
+ for name in (
67
+ 'ActorInput',
68
+ 'ActorPermissionLevel',
69
+ 'ActorRun',
70
+ 'ApifyEnv',
71
+ 'AbortOptions',
72
+ 'CallOptions',
73
+ 'ExitOptions',
74
+ 'InitOptions',
75
+ 'MetamorphOptions',
76
+ 'OpenStorageOptions',
77
+ 'ProxyInfo',
78
+ 'RebootOptions',
79
+ 'RecordOptions',
80
+ 'SetStatusMessageOptions',
81
+ 'StartOptions',
82
+ 'WebhookOptions',
83
+ )
84
+ },
85
+ # Webhook
86
+ 'Webhook': ('scrapely.webhook', 'Webhook'),
87
+ 'WebhookEventType': ('scrapely.webhook', 'WebhookEventType'),
88
+ # Pricing
89
+ **{
90
+ name: ('scrapely.pricing_types', name)
91
+ for name in (
92
+ 'PricingModel',
93
+ 'FreeActorPricingInfo',
94
+ 'FlatPricePerMonthActorPricingInfo',
95
+ 'PricePerDatasetItemActorPricingInfo',
96
+ 'PayPerEventActorPricingInfo',
97
+ 'PricingPerEvent',
98
+ 'ActorPricingInfo',
99
+ 'ActorChargeEvent',
100
+ 'ChargeResult',
101
+ 'ActorPricingInfoResult',
102
+ )
103
+ },
104
+ # Errors
105
+ **{
106
+ name: ('scrapely.errors', name)
107
+ for name in (
108
+ 'ScrapelyClientError',
109
+ 'ScrapelyApiError',
110
+ 'InvalidResponseBodyError',
111
+ 'InvalidRequestError',
112
+ 'UnauthorizedError',
113
+ 'ForbiddenError',
114
+ 'NotFoundError',
115
+ 'ConflictError',
116
+ 'RateLimitError',
117
+ 'ServerError',
118
+ 'raise_for_status',
119
+ )
120
+ },
102
121
  # Crawlee re-exports
103
122
  'Request': ('crawlee', 'Request'),
104
123
  'RequestLoader': ('crawlee.request_loaders', 'RequestLoader'),
@@ -150,15 +169,28 @@ _LAZY_ATTRS = {
150
169
  }
151
170
 
152
171
  # 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``.
172
+ # package's eager imports; ``scrapely.actor``, ``scrapely.events`` etc. keep
173
+ # working without an explicit ``import scrapely.<name>``.
155
174
  _LAZY_SUBMODULES = frozenset({
175
+ '_browser_locations',
176
+ 'actor',
177
+ 'browser_configuration',
178
+ 'cdp_retry',
179
+ 'charging',
180
+ 'charging_client',
181
+ 'configuration',
182
+ 'crypto',
183
+ 'errors',
156
184
  'events',
157
185
  'log',
186
+ 'pricing_types',
187
+ 'proxy_configuration',
158
188
  'request_loaders',
159
189
  'storage',
160
190
  'storage_clients',
161
191
  'storages',
192
+ 'types',
193
+ 'webhook',
162
194
  })
163
195
 
164
196
 
@@ -193,6 +225,40 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
193
225
  from crawlee.storage_clients.models import DatasetItemsListPage
194
226
  from crawlee.storages import Dataset, KeyValueStore, RequestQueue
195
227
 
228
+ from .actor import Actor
229
+ from .browser_configuration import BrowserConfiguration
230
+ from .charging import (
231
+ APIFY_DEFAULT_DATASET_ITEM_EVENT,
232
+ DEFAULT_DATASET_ITEM_EVENT,
233
+ SCRAPELY_DEFAULT_DATASET_ITEM_EVENT,
234
+ ChargeRejectedError,
235
+ ChargeUncertainError,
236
+ ChargingManager,
237
+ has_default_dataset_item_event_in_pricing,
238
+ is_default_dataset_item_event,
239
+ merge_charge_results,
240
+ push_data_and_charge,
241
+ resolve_default_dataset_item_event_name,
242
+ )
243
+ from .charging_client import (
244
+ USES_PUSH_DATA_INTERCEPTION,
245
+ create_charging_client,
246
+ push_data_charging_context,
247
+ )
248
+ from .configuration import Configuration
249
+ from .errors import (
250
+ ConflictError,
251
+ ForbiddenError,
252
+ InvalidRequestError,
253
+ InvalidResponseBodyError,
254
+ NotFoundError,
255
+ RateLimitError,
256
+ ScrapelyApiError,
257
+ ScrapelyClientError,
258
+ ServerError,
259
+ UnauthorizedError,
260
+ raise_for_status,
261
+ )
196
262
  from .events import (
197
263
  AbortingEvent,
198
264
  ActorEventTypes,
@@ -216,6 +282,19 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
216
282
  SystemInfoEventData,
217
283
  UnknownEvent,
218
284
  )
285
+ from .pricing_types import (
286
+ ActorChargeEvent,
287
+ ActorPricingInfo,
288
+ ActorPricingInfoResult,
289
+ ChargeResult,
290
+ FlatPricePerMonthActorPricingInfo,
291
+ FreeActorPricingInfo,
292
+ PayPerEventActorPricingInfo,
293
+ PricePerDatasetItemActorPricingInfo,
294
+ PricingModel,
295
+ PricingPerEvent,
296
+ )
297
+ from .proxy_configuration import ProxyConfiguration
219
298
  from .request_loaders import ScrapelyRequestList
220
299
  from .storage import (
221
300
  ApiStorageClient,
@@ -226,8 +305,27 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
226
305
  StorageClient,
227
306
  set_api_storage,
228
307
  )
308
+ from .types import (
309
+ AbortOptions,
310
+ ActorInput,
311
+ ActorPermissionLevel,
312
+ ActorRun,
313
+ ApifyEnv,
314
+ CallOptions,
315
+ ExitOptions,
316
+ InitOptions,
317
+ MetamorphOptions,
318
+ OpenStorageOptions,
319
+ ProxyInfo,
320
+ RebootOptions,
321
+ RecordOptions,
322
+ SetStatusMessageOptions,
323
+ StartOptions,
324
+ WebhookOptions,
325
+ )
326
+ from .webhook import Webhook, WebhookEventType
229
327
 
230
- __version__ = '0.1.41'
328
+ __version__ = '0.1.43'
231
329
  __all__ = [
232
330
  # Core
233
331
  'Actor',
@@ -11,15 +11,29 @@ from __future__ import annotations
11
11
 
12
12
  from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence
13
13
 
14
- from scrapely_client import ApifyClientAsync
14
+ if TYPE_CHECKING:
15
+ from scrapely_client import USES_PUSH_DATA_INTERCEPTION, ApifyClientAsync, push_data_charging_context
16
+
17
+ from .charging import ChargingManager
15
18
 
19
+ # ``scrapely_client`` (219 pydantic models) loads only when a client is created
20
+ # or one of its names below is used: ``Actor`` imports this module, and most
21
+ # runs never create an API client.
22
+ #
16
23
  # Re-exported so the SDK and the client share one marker attribute and one
17
24
  # context var. Identity matters: the client's intercepted ``push_items()``
18
25
  # reads/writes these, and the SDK detects interception via the attribute.
19
- from scrapely_client import USES_PUSH_DATA_INTERCEPTION, push_data_charging_context
26
+ _CLIENT_NAMES = frozenset({'ApifyClientAsync', 'USES_PUSH_DATA_INTERCEPTION', 'push_data_charging_context'})
20
27
 
21
- if TYPE_CHECKING:
22
- from .charging import ChargingManager
28
+
29
+ def __getattr__(name: str) -> Any:
30
+ if name in _CLIENT_NAMES:
31
+ import scrapely_client
32
+
33
+ value = getattr(scrapely_client, name)
34
+ globals()[name] = value
35
+ return value
36
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
23
37
 
24
38
  __all__ = [
25
39
  'USES_PUSH_DATA_INTERCEPTION',
@@ -81,7 +95,7 @@ def create_charging_client(
81
95
  charging_manager: Optional['ChargingManager'] = None,
82
96
  default_dataset_id: Optional[str] = None,
83
97
  **kwargs: Any,
84
- ) -> ApifyClientAsync:
98
+ ) -> 'ApifyClientAsync':
85
99
  """Create an ``ApifyClientAsync`` configured with the SDK's charging hooks.
86
100
 
87
101
  Args:
@@ -101,6 +115,8 @@ def create_charging_client(
101
115
  if api_public_url:
102
116
  kwargs['api_public_url'] = api_public_url
103
117
 
118
+ from scrapely_client import ApifyClientAsync
119
+
104
120
  charging_hooks = _ChargingManagerHooks(charging_manager) if charging_manager is not None else None
105
121
 
106
122
  return ApifyClientAsync(
@@ -30,11 +30,11 @@ Mirrors the Node SDK ``patch-browser-pool.ts``:
30
30
  sandbox-safe Chromium args when running on the platform.
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 on the platform). 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
+ - Neither Crawlee, Playwright, Camoufox nor pyppeteer is imported by this
34
+ module (the base images import it at every interpreter start). Each patch is
35
+ applied at once when its module is already loaded, otherwise by a
36
+ post-import hook the moment that module finishes loading — before actor code
37
+ can use what it patches.
38
38
 
39
39
  Location and proxy handling (the remote browser never takes a proxy):
40
40
 
@@ -67,19 +67,79 @@ Location and proxy handling (the remote browser never takes a proxy):
67
67
  from __future__ import annotations
68
68
 
69
69
  import contextvars
70
- import functools
71
- import importlib.abc
72
- import logging
73
70
  import os
74
- import re
75
71
  import sys
76
- from typing import Any, List, Optional, Tuple
77
- from urllib.parse import SplitResult, parse_qsl, unquote, urlencode, urlsplit, urlunsplit
78
72
 
79
- from ._browser_locations import subdivision_to_state_name
80
- from .cdp_retry import connect_with_retry_async, wrap_connect_over_cdp
73
+ # This module is imported at EVERY interpreter start in actor images (base
74
+ # image sitecustomize), before any actor code: it imports only builtin modules
75
+ # at load time. Everything else (typing, logging, re, urllib.parse, functools,
76
+ # asyncio via cdp_retry, the browser location table) loads on first use.
77
+ TYPE_CHECKING = False
78
+ if TYPE_CHECKING:
79
+ from typing import Any, List, Optional, Tuple
80
+ from urllib.parse import SplitResult
81
81
 
82
- logger = logging.getLogger(__name__)
82
+
83
+ class _LazyLogger:
84
+ """``logging.getLogger(__name__)``, imported on first use."""
85
+
86
+ def __getattr__(self, name: str) -> Any:
87
+ import logging
88
+
89
+ return getattr(logging.getLogger(__name__), name)
90
+
91
+
92
+ logger = _LazyLogger()
93
+
94
+
95
+ def urlsplit(*args: Any, **kwargs: Any) -> Any:
96
+ from urllib.parse import urlsplit as _urlsplit
97
+
98
+ return _urlsplit(*args, **kwargs)
99
+
100
+
101
+ def urlunsplit(*args: Any, **kwargs: Any) -> Any:
102
+ from urllib.parse import urlunsplit as _urlunsplit
103
+
104
+ return _urlunsplit(*args, **kwargs)
105
+
106
+
107
+ def parse_qsl(*args: Any, **kwargs: Any) -> Any:
108
+ from urllib.parse import parse_qsl as _parse_qsl
109
+
110
+ return _parse_qsl(*args, **kwargs)
111
+
112
+
113
+ def urlencode(*args: Any, **kwargs: Any) -> Any:
114
+ from urllib.parse import urlencode as _urlencode
115
+
116
+ return _urlencode(*args, **kwargs)
117
+
118
+
119
+ def unquote(*args: Any, **kwargs: Any) -> Any:
120
+ from urllib.parse import unquote as _unquote
121
+
122
+ return _unquote(*args, **kwargs)
123
+
124
+
125
+ # ``cdp_retry`` pulls in asyncio and ``_browser_locations`` is a large table;
126
+ # both are only needed once a browser is actually launched.
127
+ def subdivision_to_state_name(country: Optional[str], subdivision: Optional[str]) -> Optional[str]:
128
+ from ._browser_locations import subdivision_to_state_name as _subdivision_to_state_name
129
+
130
+ return _subdivision_to_state_name(country, subdivision)
131
+
132
+
133
+ def wrap_connect_over_cdp(*args: Any, **kwargs: Any) -> Any:
134
+ from .cdp_retry import wrap_connect_over_cdp as _wrap_connect_over_cdp
135
+
136
+ return _wrap_connect_over_cdp(*args, **kwargs)
137
+
138
+
139
+ async def connect_with_retry_async(*args: Any, **kwargs: Any) -> Any:
140
+ from .cdp_retry import connect_with_retry_async as _connect_with_retry_async
141
+
142
+ return await _connect_with_retry_async(*args, **kwargs)
83
143
 
84
144
  _cdp_url: Optional[str] = None
85
145
 
@@ -219,7 +279,7 @@ def _redact_proxy_url(proxy_url: str) -> str:
219
279
  return f'{parts.scheme or "http"}://{userinfo}{parts.hostname}{port}'
220
280
 
221
281
 
222
- _COUNTRY_FRAGMENT_RE = re.compile(r'^country-([A-Za-z]{2})(?:_([A-Za-z0-9]{1,3}))?$')
282
+ _COUNTRY_FRAGMENT_PATTERN = r'^country-([A-Za-z]{2})(?:_([A-Za-z0-9]{1,3}))?$'
223
283
 
224
284
 
225
285
  def _parse_proxy_url_location(proxy_url: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
@@ -229,8 +289,10 @@ def _parse_proxy_url_location(proxy_url: Optional[str]) -> Tuple[Optional[str],
229
289
  parts = _parse_proxy_url(str(proxy_url))
230
290
  if not parts or not parts.username:
231
291
  return None, None
292
+ import re
293
+
232
294
  for fragment in unquote(parts.username).split(','):
233
- match = _COUNTRY_FRAGMENT_RE.match(fragment.strip())
295
+ match = re.match(_COUNTRY_FRAGMENT_PATTERN, fragment.strip())
234
296
  if match:
235
297
  subdivision = match.group(2)
236
298
  return match.group(1).upper(), subdivision.upper() if subdivision else None
@@ -475,6 +537,8 @@ def _apply_crawler_patch() -> None:
475
537
  if getattr(PlaywrightCrawler, '_scrapely_browser_config_patched', False):
476
538
  return
477
539
 
540
+ import functools
541
+
478
542
  _orig_init = PlaywrightCrawler.__init__
479
543
 
480
544
  @functools.wraps(_orig_init)
@@ -784,39 +848,46 @@ class _AsyncPlaywrightContextManagerProxy:
784
848
  return await self._cm.__aexit__(*exc)
785
849
 
786
850
 
787
- def _apply_raw_playwright_patch() -> None:
788
- """Wrap playwright's sync/async entry points to redirect raw launches to CDP."""
851
+ def _apply_raw_playwright_async_patch() -> None:
852
+ """Wrap ``playwright.async_api.async_playwright`` to redirect raw launches to CDP."""
789
853
  try:
790
854
  import playwright.async_api as playwright_async_api
791
855
  except ImportError:
792
- playwright_async_api = None # type: ignore[assignment]
856
+ return
793
857
 
794
- if playwright_async_api is not None and not getattr(
795
- playwright_async_api, '_scrapely_raw_cdp_patched', False
796
- ):
797
- orig_async = playwright_async_api.async_playwright
858
+ if getattr(playwright_async_api, '_scrapely_raw_cdp_patched', False):
859
+ return
860
+ orig_async = playwright_async_api.async_playwright
861
+
862
+ def async_playwright_patched() -> Any:
863
+ return _AsyncPlaywrightContextManagerProxy(orig_async())
798
864
 
799
- def async_playwright_patched() -> Any:
800
- return _AsyncPlaywrightContextManagerProxy(orig_async())
865
+ playwright_async_api.async_playwright = async_playwright_patched
866
+ playwright_async_api._scrapely_raw_cdp_patched = True # type: ignore[attr-defined]
801
867
 
802
- playwright_async_api.async_playwright = async_playwright_patched
803
- playwright_async_api._scrapely_raw_cdp_patched = True # type: ignore[attr-defined]
804
868
 
869
+ def _apply_raw_playwright_sync_patch() -> None:
870
+ """Wrap ``playwright.sync_api.sync_playwright`` to redirect raw launches to CDP."""
805
871
  try:
806
872
  import playwright.sync_api as playwright_sync_api
807
873
  except ImportError:
808
- playwright_sync_api = None # type: ignore[assignment]
874
+ return
809
875
 
810
- if playwright_sync_api is not None and not getattr(
811
- playwright_sync_api, '_scrapely_raw_cdp_patched', False
812
- ):
813
- orig_sync = playwright_sync_api.sync_playwright
876
+ if getattr(playwright_sync_api, '_scrapely_raw_cdp_patched', False):
877
+ return
878
+ orig_sync = playwright_sync_api.sync_playwright
814
879
 
815
- def sync_playwright_patched() -> Any:
816
- return _SyncPlaywrightContextManagerProxy(orig_sync())
880
+ def sync_playwright_patched() -> Any:
881
+ return _SyncPlaywrightContextManagerProxy(orig_sync())
817
882
 
818
- playwright_sync_api.sync_playwright = sync_playwright_patched
819
- playwright_sync_api._scrapely_raw_cdp_patched = True # type: ignore[attr-defined]
883
+ playwright_sync_api.sync_playwright = sync_playwright_patched
884
+ playwright_sync_api._scrapely_raw_cdp_patched = True # type: ignore[attr-defined]
885
+
886
+
887
+ def _apply_raw_playwright_patch() -> None:
888
+ """Wrap playwright's sync/async entry points to redirect raw launches to CDP."""
889
+ _apply_raw_playwright_async_patch()
890
+ _apply_raw_playwright_sync_patch()
820
891
 
821
892
 
822
893
  def _apply_pyppeteer_patch() -> None:
@@ -874,31 +945,59 @@ def _apply_pyppeteer_patch() -> None:
874
945
  pyppeteer_mod._scrapely_cdp_patched = True
875
946
 
876
947
 
877
- # ─── Crawlee patches without importing Crawlee ───────────────────────────────
948
+ # ─── Patches applied when their library loads ────────────────────────────────
878
949
  #
879
- # Importing Crawlee costs ~1 s per run on the platform, 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.
950
+ # This module is imported at every interpreter start in actor images (base
951
+ # image sitecustomize), so it never imports Crawlee, Playwright, Camoufox or
952
+ # pyppeteer itself: importing them up front cost ~1 s per process on the
953
+ # platform even when the actor never opens a browser. Every patch mutates a
954
+ # module or class in place, so it only has to run before actor code can use
955
+ # what it patches: it is applied at once when the defining module is already
956
+ # loaded, otherwise by a post-import hook the moment that module finishes
957
+ # executing, before the import statement that loaded it returns
958
+ # (``import crawlee.browsers``, ``from crawlee.crawlers import
959
+ # PlaywrightCrawler``, ``from playwright.async_api import async_playwright``, a
960
+ # PlaywrightCrawler built by any template...). Behaviour is the same as
961
+ # applying them eagerly.
886
962
 
887
963
  # Module name -> patch functions to run once that module has finished loading.
888
964
  # ``crawlee.browsers`` imports BrowserPool, PlaywrightBrowserController and
889
965
  # PlaywrightBrowserPlugin in its ``__init__``; any import of one of those
890
- # submodules runs that ``__init__`` to completion first.
891
- _CRAWLEE_POST_IMPORT_PATCHES: dict = {
966
+ # submodules runs that ``__init__`` to completion first. Likewise importing
967
+ # ``pyppeteer.launcher`` runs the ``pyppeteer`` package first, and the
968
+ # ``camoufox`` package defines the ``AsyncNewBrowser`` the patch replaces.
969
+ _POST_IMPORT_PATCHES: dict = {
892
970
  'crawlee.browsers': (_apply_patch,),
893
971
  'crawlee.crawlers._playwright._playwright_crawler': (_apply_crawler_patch,),
972
+ 'playwright.async_api': (_apply_raw_playwright_async_patch,),
973
+ 'playwright.sync_api': (_apply_raw_playwright_sync_patch,),
974
+ 'camoufox': (_apply_camoufox_patch,),
975
+ 'pyppeteer': (_apply_pyppeteer_patch,),
894
976
  }
977
+ # Kept under its old name for code that inspects it.
978
+ _CRAWLEE_POST_IMPORT_PATCHES = _POST_IMPORT_PATCHES
979
+
980
+
981
+ def _run_patches(module_name: str, patches: tuple) -> None:
982
+ """Run the patches for a loaded module; a failing patch never breaks the actor's import."""
983
+ for patch in patches:
984
+ try:
985
+ patch()
986
+ except Exception as exc: # pragma: no cover - defensive (incompatible library version)
987
+ logger.warning(
988
+ '[scrapely] Could not patch %s for the remote browser (%s: %s)',
989
+ module_name,
990
+ type(exc).__name__,
991
+ exc,
992
+ )
895
993
 
896
994
 
897
- class _PostImportLoader(importlib.abc.Loader):
995
+ class _PostImportLoader:
898
996
  """Delegating loader that runs the Scrapely patches after the module executes."""
899
997
 
900
- def __init__(self, loader: Any, patches: tuple) -> None:
998
+ def __init__(self, loader: Any, fullname: str, patches: tuple) -> None:
901
999
  self._loader = loader
1000
+ self._fullname = fullname
902
1001
  self._patches = patches
903
1002
 
904
1003
  def create_module(self, spec: Any) -> Any:
@@ -914,22 +1013,25 @@ class _PostImportLoader(importlib.abc.Loader):
914
1013
  except Exception:
915
1014
  pass
916
1015
  self._loader.exec_module(module)
917
- for patch in self._patches:
918
- patch()
1016
+ # The module loaded: patch it now, once. A failed load (the exception
1017
+ # above) keeps the hook, so a later successful import is patched too.
1018
+ if _POST_IMPORT_PATCHES.get(self._fullname) is self._patches:
1019
+ _POST_IMPORT_PATCHES.pop(self._fullname, None)
1020
+ _run_patches(self._fullname, self._patches)
919
1021
 
920
1022
  def __getattr__(self, name: str) -> Any:
921
1023
  return getattr(self._loader, name)
922
1024
 
923
1025
 
924
- class _CrawleePostImportFinder(importlib.abc.MetaPathFinder):
925
- """Meta path finder that hooks the Crawlee modules in ``_CRAWLEE_POST_IMPORT_PATCHES``."""
1026
+ class _PostImportFinder:
1027
+ """Meta path finder that hooks the modules in ``_POST_IMPORT_PATCHES``."""
926
1028
 
927
1029
  def find_spec(self, fullname: str, path: Any = None, target: Any = None) -> Any:
928
- patches = _CRAWLEE_POST_IMPORT_PATCHES.get(fullname)
1030
+ patches = _POST_IMPORT_PATCHES.get(fullname)
929
1031
  if patches is None:
930
1032
  return None
931
1033
  for finder in sys.meta_path:
932
- if finder is self or isinstance(finder, _CrawleePostImportFinder):
1034
+ if finder is self or isinstance(finder, _PostImportFinder):
933
1035
  continue
934
1036
  find_spec = getattr(finder, 'find_spec', None)
935
1037
  if find_spec is None:
@@ -941,25 +1043,25 @@ class _CrawleePostImportFinder(importlib.abc.MetaPathFinder):
941
1043
  return None
942
1044
  if spec.loader is None or not hasattr(spec.loader, 'exec_module'):
943
1045
  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)
1046
+ # The hook stays registered until the module has really loaded
1047
+ # (``importlib.util.find_spec`` availability checks do not consume it).
1048
+ spec.loader = _PostImportLoader(spec.loader, fullname, patches)
947
1049
  return spec
948
1050
 
949
1051
 
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):
1052
+ # Kept under its old name for code that inspects ``sys.meta_path``.
1053
+ _CrawleePostImportFinder = _PostImportFinder
1054
+
1055
+
1056
+ def _install_post_import_patches() -> None:
1057
+ """Apply each patch now if its module is loaded, else when it loads."""
1058
+ for module_name in list(_POST_IMPORT_PATCHES):
953
1059
  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()
963
- _apply_camoufox_patch()
964
- _apply_raw_playwright_patch()
965
- _apply_pyppeteer_patch()
1060
+ _run_patches(module_name, _POST_IMPORT_PATCHES.pop(module_name))
1061
+ if _POST_IMPORT_PATCHES and not any(isinstance(finder, _PostImportFinder) for finder in sys.meta_path):
1062
+ sys.meta_path.insert(0, _PostImportFinder())
1063
+
1064
+
1065
+ _install_crawlee_patches = _install_post_import_patches
1066
+
1067
+ _install_post_import_patches()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.42
3
+ Version: 0.1.43
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <hello@scrape.ly>
6
6
  License-Expression: MIT AND Apache-2.0