scrapely-python 0.1.0__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.
Files changed (43) hide show
  1. scrapely/__init__.py +246 -0
  2. scrapely/_browser_locations.py +5470 -0
  3. scrapely/actor.py +1836 -0
  4. scrapely/browser_configuration.py +146 -0
  5. scrapely/charging.py +1143 -0
  6. scrapely/charging_client.py +107 -0
  7. scrapely/configuration.py +375 -0
  8. scrapely/crypto/__init__.py +21 -0
  9. scrapely/crypto/_secrets.py +210 -0
  10. scrapely/errors.py +153 -0
  11. scrapely/events/__init__.py +57 -0
  12. scrapely/events/_types.py +194 -0
  13. scrapely/events/event_manager.py +292 -0
  14. scrapely/log.py +39 -0
  15. scrapely/patch_browser_pool.py +861 -0
  16. scrapely/pricing_types.py +172 -0
  17. scrapely/proxy_configuration.py +741 -0
  18. scrapely/py.typed +2 -0
  19. scrapely/request_loaders/__init__.py +5 -0
  20. scrapely/request_loaders/_scrapely_request_list.py +169 -0
  21. scrapely/storage/__init__.py +48 -0
  22. scrapely/storage/_file_system/__init__.py +9 -0
  23. scrapely/storage/_file_system/_key_value_store_client.py +155 -0
  24. scrapely/storage/_file_system/_storage_client.py +50 -0
  25. scrapely/storage/_metadata.py +138 -0
  26. scrapely/storage/api_client.py +350 -0
  27. scrapely/storage/client.py +618 -0
  28. scrapely/storage/patch_storage.py +257 -0
  29. scrapely/storage/scrapely_dataset_client.py +305 -0
  30. scrapely/storage/scrapely_kvs_client.py +220 -0
  31. scrapely/storage/scrapely_rq_client.py +496 -0
  32. scrapely/storage/scrapely_storage_client.py +192 -0
  33. scrapely/storage/smart_scrapely_storage_client.py +109 -0
  34. scrapely/storage/types.py +601 -0
  35. scrapely/storage/utils.py +38 -0
  36. scrapely/storage_clients/__init__.py +25 -0
  37. scrapely/storages/__init__.py +5 -0
  38. scrapely/types.py +269 -0
  39. scrapely/webhook.py +82 -0
  40. scrapely_python-0.1.0.dist-info/METADATA +83 -0
  41. scrapely_python-0.1.0.dist-info/RECORD +43 -0
  42. scrapely_python-0.1.0.dist-info/WHEEL +5 -0
  43. scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,496 @@
1
+ """Scrapely cloud RequestQueueClient for Crawlee."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import timedelta
7
+ from typing import Any, Dict, Optional, Sequence, Union
8
+
9
+ from typing_extensions import Self, override
10
+
11
+ from crawlee import Request
12
+ from crawlee.storage_clients.models import ProcessedRequest as CrawleeProcessedRequest
13
+ from crawlee.storage_clients._base import RequestQueueClient
14
+ from crawlee.storage_clients.models import AddRequestsResponse, ProcessedRequest, RequestQueueMetadata, UnprocessedRequest
15
+
16
+ from .api_client import ApiClientError
17
+ from .client import DEFAULT_LOCK_SECS, RQ_BATCH_SIZE, ApiStorageClient
18
+ from ._metadata import resolve_storage_id, to_rq_metadata
19
+ from .utils import unique_key_to_request_id
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def _is_lost_lock_error(exc: ApiClientError) -> bool:
25
+ """True when the API rejected a lock operation only because this client no
26
+ longer holds the lock: 403 "Request is not locked by this client" or 409
27
+ "Request with ID ... is not locked"."""
28
+ return exc.status_code in (403, 409) and 'not locked' in str(exc).lower()
29
+
30
+
31
+ def _old_rq_to_crawlee(meta: Any) -> RequestQueueMetadata:
32
+ return to_rq_metadata(
33
+ {
34
+ 'id': meta.id,
35
+ 'name': meta.name,
36
+ 'createdAt': meta.created_at.isoformat() if getattr(meta, 'created_at', None) else None,
37
+ 'accessedAt': meta.accessed_at.isoformat() if getattr(meta, 'accessed_at', None) else None,
38
+ 'modifiedAt': meta.modified_at.isoformat() if getattr(meta, 'modified_at', None) else None,
39
+ 'hadMultipleClients': getattr(meta, 'had_multiple_clients', False),
40
+ 'handledRequestCount': getattr(meta, 'handled_request_count', 0),
41
+ 'pendingRequestCount': getattr(meta, 'pending_request_count', 0),
42
+ 'totalRequestCount': getattr(meta, 'total_request_count', 0),
43
+ },
44
+ meta.id,
45
+ )
46
+
47
+
48
+ def _request_to_api_dict(request: Request) -> Dict[str, Any]:
49
+ data = request.model_dump(by_alias=True, exclude_none=True)
50
+ # Scrapely expects plain dicts for headers/userData
51
+ if 'headers' in data and hasattr(data['headers'], 'items'):
52
+ data['headers'] = dict(data['headers'])
53
+ return data
54
+
55
+
56
+ def _request_from_api(data: Dict[str, Any]) -> Request:
57
+ # Crawlee Request accepts uniqueKey/url via model validation with aliases
58
+ payload = {
59
+ 'uniqueKey': data.get('uniqueKey') or data.get('url') or '',
60
+ 'url': data.get('url') or '',
61
+ 'method': data.get('method') or 'GET',
62
+ 'headers': data.get('headers') or {},
63
+ 'userData': data.get('userData') or {},
64
+ 'retryCount': data.get('retryCount') or 0,
65
+ 'noRetry': data.get('noRetry') or False,
66
+ 'loadedUrl': data.get('loadedUrl'),
67
+ 'handledAt': data.get('handledAt'),
68
+ 'payload': data.get('payload'),
69
+ }
70
+ return Request.model_validate(payload)
71
+
72
+
73
+ class ScrapelyRequestQueueClient(RequestQueueClient):
74
+ """Request queue client backed by Scrapely `/v2/request-queues`."""
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ api: ApiStorageClient,
80
+ metadata: RequestQueueMetadata,
81
+ client_key: Optional[str] = None,
82
+ ) -> None:
83
+ self._api = api
84
+ self._metadata = metadata
85
+ self._client_key = client_key or api.new_client_key()
86
+ # Track requests currently being processed (fetched via fetch_next_request)
87
+ # Maps unique_key -> request_id for in-progress requests
88
+ self._in_progress: Dict[str, str] = {}
89
+
90
+ @property
91
+ def id(self) -> str:
92
+ """Get the storage ID."""
93
+ return self._metadata.id
94
+
95
+ @property
96
+ def name(self) -> Optional[str]:
97
+ """Get the storage name."""
98
+ return self._metadata.name
99
+
100
+ @classmethod
101
+ async def open(
102
+ cls,
103
+ *,
104
+ api: ApiStorageClient,
105
+ id: Optional[str] = None,
106
+ name: Optional[str] = None,
107
+ alias: Optional[str] = None,
108
+ default_id: Optional[str] = None,
109
+ ) -> Self:
110
+ open_id, open_name = resolve_storage_id(
111
+ id=id, name=name, alias=alias, default_id=default_id,
112
+ )
113
+ meta = await api.get_or_create_request_queue(id=open_id, name=open_name)
114
+ return cls(api=api, metadata=_old_rq_to_crawlee(meta))
115
+
116
+ @override
117
+ async def get_metadata(self) -> RequestQueueMetadata:
118
+ meta = await self._api.rq_get(self._metadata.id)
119
+ self._metadata = _old_rq_to_crawlee(meta)
120
+ return self._metadata
121
+
122
+ @override
123
+ async def drop(self) -> None:
124
+ await self._api.rq_delete(self._metadata.id)
125
+ self._in_progress.clear()
126
+
127
+ @override
128
+ async def purge(self) -> None:
129
+ cursor: Optional[str] = None
130
+ while True:
131
+ data = await self._api.rq_list_requests(
132
+ self._metadata.id,
133
+ client_key=self._client_key,
134
+ limit=RQ_BATCH_SIZE,
135
+ cursor=cursor,
136
+ )
137
+ items = data.get('items') or []
138
+ if not items:
139
+ break
140
+ payload = []
141
+ for item in items:
142
+ if item.get('id'):
143
+ payload.append({'id': item['id']})
144
+ elif item.get('uniqueKey'):
145
+ payload.append({'uniqueKey': item['uniqueKey']})
146
+ if payload:
147
+ await self._api.rq_batch_delete_requests(self._metadata.id, payload)
148
+ cursor = data.get('nextCursor')
149
+ if not cursor or len(items) < RQ_BATCH_SIZE:
150
+ break
151
+ self._in_progress.clear()
152
+
153
+ @override
154
+ async def add_batch_of_requests(
155
+ self,
156
+ requests: Sequence[Request],
157
+ *,
158
+ forefront: bool = False,
159
+ ) -> AddRequestsResponse:
160
+ if not requests:
161
+ return AddRequestsResponse(processed_requests=[], unprocessed_requests=[])
162
+
163
+ # Chunk via ApiStorageClient (max 25)
164
+ from .types import Request as ApiRequest
165
+
166
+ api_requests = []
167
+ for req in requests:
168
+ d = _request_to_api_dict(req)
169
+ api_requests.append(ApiRequest.from_dict(d))
170
+
171
+ result = await self._api.rq_batch_add_requests(
172
+ self._metadata.id,
173
+ api_requests,
174
+ client_key=self._client_key,
175
+ forefront=forefront,
176
+ )
177
+
178
+ processed = []
179
+ for item in result.get('processedRequests') or []:
180
+ unique_key = str(item.get('uniqueKey') or '')
181
+ # Use deterministic ID computation - same unique_key always produces the same ID
182
+ # The server should return the same ID if it uses the same algorithm
183
+ request_id = str(item.get('requestId') or '') or unique_key_to_request_id(unique_key)
184
+ processed.append(
185
+ ProcessedRequest(
186
+ id=request_id or None,
187
+ unique_key=unique_key,
188
+ was_already_present=bool(item.get('wasAlreadyPresent')),
189
+ was_already_handled=bool(item.get('wasAlreadyHandled')),
190
+ )
191
+ )
192
+
193
+ unprocessed = []
194
+ for item in result.get('unprocessedRequests') or []:
195
+ unprocessed.append(
196
+ UnprocessedRequest(
197
+ unique_key=str(item.get('uniqueKey') or item.get('url') or ''),
198
+ url=str(item.get('url') or 'https://invalid.invalid'),
199
+ method=item.get('method'),
200
+ )
201
+ )
202
+
203
+ return AddRequestsResponse(
204
+ processed_requests=processed,
205
+ unprocessed_requests=unprocessed,
206
+ )
207
+
208
+ @override
209
+ async def get_request(self, unique_key: str) -> Optional[Request]:
210
+ # Use deterministic ID computation - same unique_key always produces the same ID
211
+ request_id = unique_key_to_request_id(unique_key)
212
+ api_req = await self._api.rq_get_request(self._metadata.id, request_id)
213
+ if api_req is None:
214
+ return None
215
+ return _request_from_api({
216
+ 'id': api_req.id,
217
+ 'uniqueKey': api_req.unique_key,
218
+ 'url': api_req.url,
219
+ 'method': api_req.method,
220
+ 'headers': api_req.headers,
221
+ 'userData': api_req.user_data,
222
+ 'retryCount': api_req.retry_count,
223
+ 'handledAt': api_req.handled_at,
224
+ })
225
+
226
+ @override
227
+ async def fetch_next_request(self) -> Optional[Request]:
228
+ head = await self._api.rq_list_and_lock_head(
229
+ self._metadata.id,
230
+ client_key=self._client_key,
231
+ lock_secs=DEFAULT_LOCK_SECS,
232
+ limit=1,
233
+ )
234
+ if not head.items:
235
+ return None
236
+ api_req = head.items[0]
237
+ request = _request_from_api({
238
+ 'id': api_req.id,
239
+ 'uniqueKey': api_req.unique_key,
240
+ 'url': api_req.url,
241
+ 'method': api_req.method,
242
+ 'headers': api_req.headers,
243
+ 'userData': api_req.user_data,
244
+ 'retryCount': api_req.retry_count,
245
+ 'handledAt': api_req.handled_at,
246
+ 'lockExpiresAt': api_req.lock_expires_at,
247
+ })
248
+ if api_req.id:
249
+ self._in_progress[request.unique_key] = api_req.id
250
+ return request
251
+
252
+ @override
253
+ async def mark_request_as_handled(self, request: Request) -> Optional[ProcessedRequest]:
254
+ # Use deterministic ID computation - same unique_key always produces the same ID
255
+ # Also check in_progress cache for requests fetched via fetch_next_request
256
+ request_id = self._in_progress.get(request.unique_key) or unique_key_to_request_id(request.unique_key)
257
+
258
+ from .types import Request as ApiRequest
259
+
260
+ api_req = ApiRequest.from_dict(_request_to_api_dict(request))
261
+ api_req.id = request_id
262
+ result = await self._api.rq_mark_request_handled(
263
+ self._metadata.id,
264
+ api_req,
265
+ client_key=self._client_key,
266
+ )
267
+ self._in_progress.pop(request.unique_key, None)
268
+ return ProcessedRequest(
269
+ id=result.id or request_id,
270
+ unique_key=request.unique_key,
271
+ was_already_present=result.was_already_present,
272
+ was_already_handled=result.was_already_handled,
273
+ )
274
+
275
+ @override
276
+ async def reclaim_request(
277
+ self,
278
+ request: Request,
279
+ *,
280
+ forefront: bool = False,
281
+ ) -> Optional[ProcessedRequest]:
282
+ # Use deterministic ID computation - same unique_key always produces the same ID
283
+ # Also check in_progress cache for requests fetched via fetch_next_request
284
+ request_id = self._in_progress.get(request.unique_key) or unique_key_to_request_id(request.unique_key)
285
+
286
+ from .types import Request as ApiRequest
287
+
288
+ api_req = ApiRequest.from_dict(_request_to_api_dict(request))
289
+ api_req.id = request_id
290
+ try:
291
+ result = await self._api.rq_reclaim_request(
292
+ self._metadata.id,
293
+ api_req,
294
+ client_key=self._client_key,
295
+ forefront=forefront,
296
+ )
297
+ result_id = result.id
298
+ except ApiClientError as exc:
299
+ # Only a lost lock is safe to ignore: this client no longer holds it
300
+ # (it expired and the request was handed out again, or it was
301
+ # already released). The request is still in the queue, so there is
302
+ # nothing to undo, and raising would make Crawlee abort the crawl.
303
+ # Any other failure, including other 403s (bad token, missing
304
+ # permissions), is still raised.
305
+ if not _is_lost_lock_error(exc):
306
+ raise
307
+ logger.warning(
308
+ 'Could not release lock on request %s (%s); it was likely already '
309
+ 'released or re-locked after its lock expired. Continuing.',
310
+ request_id,
311
+ exc,
312
+ )
313
+ result_id = None
314
+ self._in_progress.pop(request.unique_key, None)
315
+ return ProcessedRequest(
316
+ id=result_id or request_id,
317
+ unique_key=request.unique_key,
318
+ was_already_present=True,
319
+ was_already_handled=False,
320
+ )
321
+
322
+ async def prolong_request_lock(
323
+ self,
324
+ request: Request,
325
+ *,
326
+ lock_secs: int = DEFAULT_LOCK_SECS,
327
+ forefront: bool = False,
328
+ ) -> Optional[Dict[str, Any]]:
329
+ """Prolong the lock on a request being processed.
330
+
331
+ Use this when processing takes longer than the initial lock duration
332
+ to prevent the request from being returned to other consumers.
333
+
334
+ Args:
335
+ request: The request whose lock should be prolonged.
336
+ lock_secs: Number of seconds to prolong the lock for.
337
+ forefront: If True, move the request to the front of the queue
338
+ if the lock expires.
339
+
340
+ Returns:
341
+ Dict with 'lockExpiresAt' timestamp, or None if the request
342
+ was not in progress.
343
+ """
344
+ request_id = self._in_progress.get(request.unique_key) or unique_key_to_request_id(request.unique_key)
345
+ if request_id not in self._in_progress.values():
346
+ # Request not currently locked by this client
347
+ return None
348
+ result = await self._api.rq_prolong_request_lock(
349
+ self._metadata.id,
350
+ request_id,
351
+ client_key=self._client_key,
352
+ lock_secs=lock_secs,
353
+ forefront=forefront,
354
+ )
355
+ return result
356
+
357
+ async def unlock_requests(
358
+ self,
359
+ *,
360
+ forefront: bool = False,
361
+ ) -> Dict[str, Any]:
362
+ """Unlock all requests locked by this client.
363
+
364
+ Useful for cleanup when a client is done processing or when
365
+ recovering from errors. This releases all locks held by this
366
+ client's clientKey.
367
+
368
+ Args:
369
+ forefront: If True, move unlocked requests to the front of the queue.
370
+
371
+ Returns:
372
+ Dict with 'unlockedCount' indicating how many requests were unlocked.
373
+ """
374
+ result = await self._api.rq_unlock_requests(
375
+ self._metadata.id,
376
+ client_key=self._client_key,
377
+ forefront=forefront,
378
+ )
379
+ self._in_progress.clear()
380
+ return result
381
+
382
+ async def add_request(
383
+ self,
384
+ request: Union[str, Request],
385
+ *,
386
+ forefront: bool = False,
387
+ ) -> Optional[ProcessedRequest]:
388
+ """Add a single request to the queue.
389
+
390
+ Args:
391
+ request: The request object or URL string to add.
392
+ forefront: If True, add to the front of the queue.
393
+
394
+ Returns:
395
+ ProcessedRequest with info about the operation, or None if not added.
396
+ """
397
+ if isinstance(request, str):
398
+ # crawlee's Request requires uniqueKey — from_url computes it.
399
+ request = Request.from_url(request)
400
+
401
+ from .types import Request as ApiRequest
402
+
403
+ api_req = ApiRequest.from_dict(_request_to_api_dict(request))
404
+ result = await self._api.rq_add_request(
405
+ self._metadata.id,
406
+ api_req,
407
+ client_key=self._client_key,
408
+ forefront=forefront,
409
+ )
410
+ return CrawleeProcessedRequest(
411
+ id=result.id,
412
+ unique_key=result.unique_key,
413
+ was_already_present=result.was_already_present,
414
+ was_already_handled=result.was_already_handled,
415
+ )
416
+
417
+ async def add_requests(
418
+ self,
419
+ requests: Sequence[Union[str, Request]],
420
+ *,
421
+ forefront: bool = False,
422
+ batch_size: int = 1000,
423
+ wait_time_between_batches: timedelta = timedelta(seconds=1),
424
+ wait_for_all_requests_to_be_added: bool = False,
425
+ wait_for_all_requests_to_be_added_timeout: Optional[timedelta] = None,
426
+ ) -> None:
427
+ """Add multiple requests to the queue in batches.
428
+
429
+ Args:
430
+ requests: Sequence of request objects or URL strings.
431
+ forefront: If True, add to the front of the queue.
432
+ batch_size: Number of requests per batch (max 25 for API).
433
+ wait_time_between_batches: Time to wait between batches.
434
+ wait_for_all_requests_to_be_added: If True, wait for all to be added.
435
+ wait_for_all_requests_to_be_added_timeout: Timeout for waiting.
436
+ """
437
+ import asyncio
438
+
439
+ # Convert strings to Request objects
440
+ converted: list[Request] = []
441
+ for req in requests:
442
+ if isinstance(req, str):
443
+ converted.append(Request.from_url(req))
444
+ else:
445
+ converted.append(req)
446
+
447
+ # API batch size is 25, so we use that regardless of batch_size param
448
+ api_batch_size = min(batch_size, RQ_BATCH_SIZE)
449
+
450
+ for i in range(0, len(converted), api_batch_size):
451
+ chunk = converted[i:i + api_batch_size]
452
+ await self.add_batch_of_requests(chunk, forefront=forefront)
453
+
454
+ # Wait between batches if there are more to process
455
+ if i + api_batch_size < len(converted):
456
+ await asyncio.sleep(wait_time_between_batches.total_seconds())
457
+
458
+ async def get_handled_count(self) -> int:
459
+ """Get the number of handled requests in the queue."""
460
+ meta = await self.get_metadata()
461
+ return meta.handled_request_count
462
+
463
+ async def get_total_count(self) -> int:
464
+ """Get the total number of requests in the queue (pending + handled)."""
465
+ meta = await self.get_metadata()
466
+ return meta.total_request_count
467
+
468
+ async def to_tandem(
469
+ self,
470
+ request_manager: Optional['RequestQueueClient'] = None,
471
+ ) -> Any:
472
+ """Combine this loader with a request manager.
473
+
474
+ Args:
475
+ request_manager: Request manager to combine with. If None, uses default.
476
+
477
+ Returns:
478
+ RequestManagerTandem combining this loader with the manager.
479
+ """
480
+ from crawlee.request_loaders import RequestManagerTandem
481
+ manager: Any = request_manager
482
+ if manager is None:
483
+ # Use the default request queue as the manager
484
+ from crawlee.storages import RequestQueue
485
+ manager = await RequestQueue.open()
486
+ return RequestManagerTandem(self, manager) # type: ignore[arg-type] # duck-typed loader
487
+
488
+ @override
489
+ async def is_empty(self) -> bool:
490
+ meta = await self.get_metadata()
491
+ return meta.pending_request_count == 0 and not self._in_progress
492
+
493
+ @override
494
+ async def is_finished(self) -> bool:
495
+ meta = await self.get_metadata()
496
+ return meta.pending_request_count == 0 and not self._in_progress
@@ -0,0 +1,192 @@
1
+ """Scrapely cloud StorageClient for Crawlee (like ApifyStorageClient / TS ApiStorageClientFactory)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Literal, Optional
7
+
8
+ from typing_extensions import override
9
+
10
+ from crawlee.configuration import Configuration
11
+ from crawlee.storage_clients._base import StorageClient
12
+
13
+ from scrapely.configuration import Configuration as ScrapelyConfiguration
14
+
15
+ from .api_client import ApiClient
16
+ from .client import ApiStorageClient
17
+ from .scrapely_dataset_client import ScrapelyDatasetClient
18
+ from .scrapely_kvs_client import ScrapelyKeyValueStoreClient
19
+ from .scrapely_rq_client import ScrapelyRequestQueueClient
20
+
21
+
22
+ class ScrapelyStorageClient(StorageClient):
23
+ """Cloud storage client that persists datasets, KVS, and RQs via Scrapely ``/v2``.
24
+
25
+ Mirrors Apify's ``ApifyStorageClient`` and the TypeScript ``ApiStorageClientFactory``.
26
+ The Scrapely server backs these endpoints with Dragonfly; actors never open Dragonfly directly.
27
+
28
+ ``request_queue_access`` is accepted for Apify API compatibility. Scrapely ``/v2`` uses a single
29
+ head-lock RQ client (same as TypeScript); ``shared`` currently behaves like ``single``.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ request_queue_access: Literal['single', 'shared'] = 'single',
36
+ api_url: Optional[str] = None,
37
+ token: Optional[str] = None,
38
+ run_id: Optional[str] = None,
39
+ api: Optional[ApiStorageClient] = None,
40
+ transport: Any = None,
41
+ ) -> None:
42
+ self._request_queue_access = request_queue_access
43
+ self._api_url = api_url
44
+ self._token = token
45
+ self._run_id = run_id
46
+ self._transport = transport
47
+ self._api = api
48
+
49
+ def has_credentials(self) -> bool:
50
+ if self._api is not None:
51
+ return True
52
+ url, token, run_id = self._resolve_credentials()
53
+ return bool(url and token and run_id)
54
+
55
+ def _resolve_credentials(self) -> tuple[Optional[str], Optional[str], Optional[str]]:
56
+ scrapely = ScrapelyConfiguration.get_global_config()
57
+ url = (
58
+ self._api_url
59
+ or scrapely.get('api_base_url')
60
+ or os.environ.get('SCRAPELY_API_URL')
61
+ or os.environ.get('APIFY_API_BASE_URL')
62
+ )
63
+ token = (
64
+ self._token
65
+ or scrapely.token
66
+ or os.environ.get('SCRAPELY_TOKEN')
67
+ or os.environ.get('APIFY_TOKEN')
68
+ )
69
+ run_id = (
70
+ self._run_id
71
+ or scrapely.actor_run_id
72
+ or os.environ.get('ACTOR_RUN_ID')
73
+ or os.environ.get('APIFY_ACTOR_RUN_ID')
74
+ )
75
+ return url, token, run_id
76
+
77
+ def _ensure_api(self) -> ApiStorageClient:
78
+ if self._api is not None:
79
+ return self._api
80
+ url, token, run_id = self._resolve_credentials()
81
+ if not url or not token or not run_id:
82
+ raise ValueError(
83
+ 'ScrapelyStorageClient requires api_url, token, and run_id '
84
+ '(or SCRAPELY_API_URL / SCRAPELY_TOKEN / ACTOR_RUN_ID env vars).'
85
+ )
86
+ self._api = ApiStorageClient(
87
+ api_url=url,
88
+ token=token,
89
+ run_id=run_id,
90
+ transport=self._transport,
91
+ )
92
+ return self._api
93
+
94
+ @property
95
+ def api(self) -> ApiStorageClient:
96
+ return self._ensure_api()
97
+
98
+ @property
99
+ def api_client(self) -> ApiClient:
100
+ return self._ensure_api().api_client
101
+
102
+ async def close(self) -> None:
103
+ if self._api is not None:
104
+ await self._api.close()
105
+
106
+ def get_storage_client_cache_key(self, configuration: Configuration) -> Any:
107
+ run_id = self._run_id or self._resolve_credentials()[2] or 'unknown'
108
+ return (
109
+ f'{self.__class__.__module__}.{self.__class__.__name__}',
110
+ run_id,
111
+ self._request_queue_access,
112
+ )
113
+
114
+ def _default_dataset_id(self) -> Optional[str]:
115
+ return (
116
+ os.environ.get('ACTOR_DEFAULT_DATASET_ID')
117
+ or os.environ.get('APIFY_DEFAULT_DATASET_ID')
118
+ )
119
+
120
+ def _default_kvs_id(self) -> Optional[str]:
121
+ return (
122
+ os.environ.get('ACTOR_DEFAULT_KEY_VALUE_STORE_ID')
123
+ or os.environ.get('APIFY_DEFAULT_KEY_VALUE_STORE_ID')
124
+ )
125
+
126
+ def _default_rq_id(self) -> Optional[str]:
127
+ return (
128
+ os.environ.get('ACTOR_DEFAULT_REQUEST_QUEUE_ID')
129
+ or os.environ.get('APIFY_DEFAULT_REQUEST_QUEUE_ID')
130
+ )
131
+
132
+ @override
133
+ async def create_dataset_client(
134
+ self,
135
+ *,
136
+ id: Optional[str] = None,
137
+ name: Optional[str] = None,
138
+ alias: Optional[str] = None,
139
+ configuration: Optional[Configuration] = None,
140
+ ) -> ScrapelyDatasetClient:
141
+ configuration = configuration or Configuration.get_global_configuration()
142
+ client = await ScrapelyDatasetClient.open(
143
+ api=self._ensure_api(),
144
+ id=id,
145
+ name=name,
146
+ alias=alias,
147
+ default_id=self._default_dataset_id(),
148
+ )
149
+ await self._purge_if_needed(client, configuration)
150
+ return client
151
+
152
+ @override
153
+ async def create_kvs_client(
154
+ self,
155
+ *,
156
+ id: Optional[str] = None,
157
+ name: Optional[str] = None,
158
+ alias: Optional[str] = None,
159
+ configuration: Optional[Configuration] = None,
160
+ ) -> ScrapelyKeyValueStoreClient:
161
+ configuration = configuration or Configuration.get_global_configuration()
162
+ client = await ScrapelyKeyValueStoreClient.open(
163
+ api=self._ensure_api(),
164
+ id=id,
165
+ name=name,
166
+ alias=alias,
167
+ default_id=self._default_kvs_id(),
168
+ )
169
+ await self._purge_if_needed(client, configuration)
170
+ return client
171
+
172
+ @override
173
+ async def create_rq_client(
174
+ self,
175
+ *,
176
+ id: Optional[str] = None,
177
+ name: Optional[str] = None,
178
+ alias: Optional[str] = None,
179
+ configuration: Optional[Configuration] = None,
180
+ ) -> ScrapelyRequestQueueClient:
181
+ configuration = configuration or Configuration.get_global_configuration()
182
+ # Scrapely /v2 uses one head-lock client (TS parity); access mode is API-compat only.
183
+ _ = self._request_queue_access
184
+ client = await ScrapelyRequestQueueClient.open(
185
+ api=self._ensure_api(),
186
+ id=id,
187
+ name=name,
188
+ alias=alias,
189
+ default_id=self._default_rq_id(),
190
+ )
191
+ await self._purge_if_needed(client, configuration)
192
+ return client