scrapely-python 0.1.5__tar.gz → 0.1.6__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.
Potentially problematic release.
This version of scrapely-python might be problematic. Click here for more details.
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/PKG-INFO +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/pyproject.toml +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/actor.py +43 -10
- scrapely_python-0.1.6/src/scrapely/cdp_retry.py +226 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/patch_browser_pool.py +14 -3
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/SOURCES.txt +4 -1
- scrapely_python-0.1.6/tests/test_cdp_retry.py +169 -0
- scrapely_python-0.1.6/tests/test_terminal_status_message.py +130 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/README.md +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/setup.cfg +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/charging.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/api_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_api_client_retries.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.5 → scrapely_python-0.1.6}/tests/test_storage.py +0 -0
|
@@ -18,7 +18,7 @@ import warnings
|
|
|
18
18
|
from collections.abc import MutableMapping
|
|
19
19
|
from datetime import datetime, timedelta
|
|
20
20
|
from decimal import Decimal
|
|
21
|
-
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union, cast
|
|
21
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union, cast
|
|
22
22
|
|
|
23
23
|
from crawlee.configuration import Configuration as CrawleeConfiguration
|
|
24
24
|
from crawlee.storage_clients._base import StorageClient
|
|
@@ -287,6 +287,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
287
287
|
cls._exit_code = self._instance_exit_code
|
|
288
288
|
if self._instance_status_message:
|
|
289
289
|
cls._status_message = self._instance_status_message
|
|
290
|
+
await cls._persist_terminal_status_message(self._instance_status_message)
|
|
290
291
|
await cls._shutdown(timeout_secs=int(self._cleanup_timeout.total_seconds()))
|
|
291
292
|
|
|
292
293
|
@classmethod
|
|
@@ -589,6 +590,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
589
590
|
cls._exit_code = opts.exit_code
|
|
590
591
|
cls._status_message = opts.status_message
|
|
591
592
|
|
|
593
|
+
await cls._persist_terminal_status_message(opts.status_message)
|
|
592
594
|
await cls._shutdown(timeout_secs=opts.timeout_secs)
|
|
593
595
|
|
|
594
596
|
if opts.exit:
|
|
@@ -1021,13 +1023,32 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1021
1023
|
else:
|
|
1022
1024
|
logger.info(logged)
|
|
1023
1025
|
|
|
1026
|
+
result = await cls._put_run_status_message(message, is_terminal=is_terminal)
|
|
1027
|
+
if result is None:
|
|
1028
|
+
return ActorRun(id='', actor_id='', status='')
|
|
1029
|
+
run_id, data = result
|
|
1030
|
+
|
|
1031
|
+
return ActorRun(
|
|
1032
|
+
id=data.get('id', run_id),
|
|
1033
|
+
actor_id=data.get('actorId', ''),
|
|
1034
|
+
status=data.get('status', ''),
|
|
1035
|
+
started_at=data.get('startedAt'),
|
|
1036
|
+
finished_at=data.get('finishedAt'),
|
|
1037
|
+
status_message=data.get('statusMessage'),
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
@classmethod
|
|
1041
|
+
async def _put_run_status_message(
|
|
1042
|
+
cls, message: str, *, is_terminal: bool
|
|
1043
|
+
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
|
1044
|
+
"""PUT the status message to the current run. Returns ``None`` when not on the platform."""
|
|
1024
1045
|
config = cls.get_config()
|
|
1025
1046
|
api_url = config.get('api_base_url')
|
|
1026
1047
|
token = config.get('token')
|
|
1027
1048
|
run_id = config.get('actor_run_id')
|
|
1028
1049
|
|
|
1029
1050
|
if not api_url or not token or not run_id:
|
|
1030
|
-
return
|
|
1051
|
+
return None
|
|
1031
1052
|
|
|
1032
1053
|
client = cls.new_client(token=token, base_url=api_url)
|
|
1033
1054
|
response = await client.put(
|
|
@@ -1041,15 +1062,27 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1041
1062
|
if response.content:
|
|
1042
1063
|
body = response.json()
|
|
1043
1064
|
data = body.get('data', body) if isinstance(body, dict) else {}
|
|
1065
|
+
return run_id, data if isinstance(data, dict) else {}
|
|
1044
1066
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1067
|
+
@classmethod
|
|
1068
|
+
async def _persist_terminal_status_message(cls, message: Optional[str], timeout_secs: float = 10.0) -> None:
|
|
1069
|
+
"""Store the exit/fail status message on the run as terminal (Apify parity).
|
|
1070
|
+
|
|
1071
|
+
Runs before teardown so it lands while the run still exists and the
|
|
1072
|
+
process is alive, on success and failure alike. Best effort: a failed
|
|
1073
|
+
update is logged and never blocks the exit.
|
|
1074
|
+
"""
|
|
1075
|
+
if not message or not cls._initialized or cls._config is None:
|
|
1076
|
+
return
|
|
1077
|
+
try:
|
|
1078
|
+
await asyncio.wait_for(
|
|
1079
|
+
cls._put_run_status_message(message, is_terminal=True),
|
|
1080
|
+
timeout=max(1.0, timeout_secs),
|
|
1081
|
+
)
|
|
1082
|
+
except asyncio.CancelledError:
|
|
1083
|
+
raise
|
|
1084
|
+
except BaseException as e:
|
|
1085
|
+
logger.warning('Failed to store the final status message on the run: %s', e)
|
|
1053
1086
|
|
|
1054
1087
|
@_ClassProperty
|
|
1055
1088
|
def log(cls) -> logging.Logger:
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Retry remote-browser (CDP) connects that fail for transient reasons.
|
|
2
|
+
|
|
3
|
+
The remote browser answers ``429`` when it is at capacity and ``503`` when it
|
|
4
|
+
cannot start a browser or validate the session right now, both with a
|
|
5
|
+
``Retry-After`` delay (also repeated in the response body, because Playwright
|
|
6
|
+
only puts the status line and body into its error message). Connection errors
|
|
7
|
+
and timeouts are transient too. Those are retried with exponential backoff,
|
|
8
|
+
waiting at least the ``Retry-After`` delay when the error carries one. Any
|
|
9
|
+
other status (``400``/``401``/``402``/``403``/``404``) fails at once.
|
|
10
|
+
|
|
11
|
+
Tuning (environment):
|
|
12
|
+
|
|
13
|
+
- ``SCRAPELY_CDP_CONNECT_ATTEMPTS``: total attempts, default ``5`` (``1`` disables retries).
|
|
14
|
+
- ``SCRAPELY_CDP_RETRY_BASE_SECS``: first backoff delay, default ``1``; doubles per attempt.
|
|
15
|
+
- ``SCRAPELY_CDP_RETRY_MAX_SECS``: cap for one backoff delay, default ``30``.
|
|
16
|
+
- ``SCRAPELY_CDP_RETRY_AFTER_MAX_SECS``: longest ``Retry-After`` the SDK waits
|
|
17
|
+
out, default ``120``. A longer ``Retry-After`` ends the retries (the SDK never
|
|
18
|
+
retries earlier than the server asked).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import functools
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import random
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
from typing import Any, Awaitable, Callable, Optional, Tuple, TypeVar
|
|
31
|
+
from urllib.parse import urlsplit
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger('scrapely.browser')
|
|
34
|
+
|
|
35
|
+
T = TypeVar('T')
|
|
36
|
+
|
|
37
|
+
_RETRY_WRAPPED_ATTR = '_scrapely_cdp_retry_wrapped'
|
|
38
|
+
|
|
39
|
+
_REASONS = (
|
|
40
|
+
'Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout|'
|
|
41
|
+
'Request Timeout|Bad Request|Unauthorized|Payment Required|Forbidden|Not Found'
|
|
42
|
+
)
|
|
43
|
+
_STATUS_RE = re.compile(rf'\b([1-5]\d\d) (?:{_REASONS})\b')
|
|
44
|
+
_WS_STATUS_RE = re.compile(r'Unexpected server response:?\s*(\d{3})', re.IGNORECASE)
|
|
45
|
+
_RETRY_AFTER_RE = re.compile(r'Retry-After:?\s*(\d+(?:\.\d+)?)', re.IGNORECASE)
|
|
46
|
+
_TRANSIENT_MARKERS = (
|
|
47
|
+
'econnrefused',
|
|
48
|
+
'econnreset',
|
|
49
|
+
'etimedout',
|
|
50
|
+
'epipe',
|
|
51
|
+
'eai_again',
|
|
52
|
+
'ehostunreach',
|
|
53
|
+
'enetunreach',
|
|
54
|
+
'socket hang up',
|
|
55
|
+
'timeout',
|
|
56
|
+
'timed out',
|
|
57
|
+
'connection closed',
|
|
58
|
+
'connection refused',
|
|
59
|
+
'connection reset',
|
|
60
|
+
'websocket error',
|
|
61
|
+
'net::err_',
|
|
62
|
+
'target closed',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _env_float(name: str, default: float) -> float:
|
|
67
|
+
try:
|
|
68
|
+
value = float(os.environ.get(name, ''))
|
|
69
|
+
except ValueError:
|
|
70
|
+
return default
|
|
71
|
+
return value if value > 0 else default
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _max_attempts() -> int:
|
|
75
|
+
return max(1, int(_env_float('SCRAPELY_CDP_CONNECT_ATTEMPTS', 5)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_status(message: str) -> Optional[int]:
|
|
79
|
+
"""HTTP status from a Playwright/Puppeteer connect error message, if any."""
|
|
80
|
+
match = _WS_STATUS_RE.search(message) or _STATUS_RE.search(message)
|
|
81
|
+
return int(match.group(1)) if match else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_retry_after(message: str) -> Optional[float]:
|
|
85
|
+
match = _RETRY_AFTER_RE.search(message)
|
|
86
|
+
if not match:
|
|
87
|
+
return None
|
|
88
|
+
return float(match.group(1))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def classify_connect_error(err: BaseException) -> Tuple[bool, Optional[int], Optional[float]]:
|
|
92
|
+
"""Return ``(retryable, status, retry_after_secs)`` for a connect error."""
|
|
93
|
+
message = f'{type(err).__name__}: {err}'
|
|
94
|
+
status = parse_status(message)
|
|
95
|
+
retry_after = parse_retry_after(message)
|
|
96
|
+
if status is not None:
|
|
97
|
+
return (status == 408 or status == 429 or status >= 500), status, retry_after
|
|
98
|
+
if isinstance(err, (asyncio.TimeoutError, TimeoutError, ConnectionError, OSError)):
|
|
99
|
+
return True, None, retry_after
|
|
100
|
+
lowered = message.lower()
|
|
101
|
+
return any(marker in lowered for marker in _TRANSIENT_MARKERS), None, retry_after
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def backoff_delay(attempt: int, retry_after: Optional[float]) -> float:
|
|
105
|
+
"""Delay before retry number ``attempt`` (1-based), honoring Retry-After."""
|
|
106
|
+
base = _env_float('SCRAPELY_CDP_RETRY_BASE_SECS', 1.0)
|
|
107
|
+
cap = _env_float('SCRAPELY_CDP_RETRY_MAX_SECS', 30.0)
|
|
108
|
+
delay = min(cap, base * (2 ** (attempt - 1)))
|
|
109
|
+
delay = delay * (0.75 + random.random() * 0.5)
|
|
110
|
+
if retry_after is not None:
|
|
111
|
+
delay = max(delay, retry_after)
|
|
112
|
+
return delay
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _retry_after_too_long(retry_after: Optional[float]) -> bool:
|
|
116
|
+
return retry_after is not None and retry_after > _env_float('SCRAPELY_CDP_RETRY_AFTER_MAX_SECS', 120.0)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _describe(err: BaseException, status: Optional[int]) -> str:
|
|
120
|
+
if status is not None:
|
|
121
|
+
return f'HTTP {status}'
|
|
122
|
+
# First line only: connect errors can carry the session URL with ids.
|
|
123
|
+
text = str(err).strip().splitlines()[0] if str(err).strip() else type(err).__name__
|
|
124
|
+
return re.sub(r'(wss?|https?)://\S+', '<cdp-url>', text)[:200]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _log_retry(err: BaseException, status: Optional[int], attempt: int, attempts: int, delay: float) -> None:
|
|
128
|
+
logger.warning(
|
|
129
|
+
'Remote browser connect failed (%s); retrying in %.1fs (attempt %d/%d)',
|
|
130
|
+
_describe(err, status),
|
|
131
|
+
delay,
|
|
132
|
+
attempt + 1,
|
|
133
|
+
attempts,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def connect_with_retry_async(connect: Callable[[], Awaitable[T]]) -> T:
|
|
138
|
+
"""Run an async CDP connect, retrying transient failures."""
|
|
139
|
+
attempts = _max_attempts()
|
|
140
|
+
attempt = 1
|
|
141
|
+
while True:
|
|
142
|
+
try:
|
|
143
|
+
return await connect()
|
|
144
|
+
except asyncio.CancelledError:
|
|
145
|
+
raise
|
|
146
|
+
except Exception as err:
|
|
147
|
+
retryable, status, retry_after = classify_connect_error(err)
|
|
148
|
+
# Never retry earlier than the server asked; give up instead of waiting that long.
|
|
149
|
+
if not retryable or attempt >= attempts or _retry_after_too_long(retry_after):
|
|
150
|
+
raise
|
|
151
|
+
delay = backoff_delay(attempt, retry_after)
|
|
152
|
+
_log_retry(err, status, attempt, attempts, delay)
|
|
153
|
+
await asyncio.sleep(delay)
|
|
154
|
+
attempt += 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def connect_with_retry_sync(connect: Callable[[], T]) -> T:
|
|
158
|
+
"""Run a blocking CDP connect, retrying transient failures."""
|
|
159
|
+
attempts = _max_attempts()
|
|
160
|
+
attempt = 1
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
return connect()
|
|
164
|
+
except Exception as err:
|
|
165
|
+
retryable, status, retry_after = classify_connect_error(err)
|
|
166
|
+
# Never retry earlier than the server asked; give up instead of waiting that long.
|
|
167
|
+
if not retryable or attempt >= attempts or _retry_after_too_long(retry_after):
|
|
168
|
+
raise
|
|
169
|
+
delay = backoff_delay(attempt, retry_after)
|
|
170
|
+
_log_retry(err, status, attempt, attempts, delay)
|
|
171
|
+
time.sleep(delay)
|
|
172
|
+
attempt += 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_platform_cdp_url(url: Any, platform_cdp_url: Optional[str]) -> bool:
|
|
176
|
+
"""True when ``url`` points at the same host as the platform's CDP_URL."""
|
|
177
|
+
if not isinstance(url, str) or not platform_cdp_url:
|
|
178
|
+
return False
|
|
179
|
+
try:
|
|
180
|
+
target, platform = urlsplit(url), urlsplit(platform_cdp_url)
|
|
181
|
+
except ValueError:
|
|
182
|
+
return False
|
|
183
|
+
return bool(target.netloc) and target.netloc.lower() == platform.netloc.lower()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def wrap_connect_over_cdp(chromium: Any, is_async: bool, get_platform_cdp_url: Callable[[], Optional[str]]) -> None:
|
|
187
|
+
"""Make ``chromium.connect_over_cdp`` retry transient failures for the platform CDP host.
|
|
188
|
+
|
|
189
|
+
Covers actor code that connects to ``CDP_URL`` directly as well as the
|
|
190
|
+
SDK's own redirected launches. Connects to other hosts are left alone.
|
|
191
|
+
Idempotent.
|
|
192
|
+
"""
|
|
193
|
+
original = getattr(chromium, 'connect_over_cdp', None)
|
|
194
|
+
if original is None or getattr(original, _RETRY_WRAPPED_ATTR, False):
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
def _target(args: Tuple[Any, ...], kwargs: dict) -> Any:
|
|
198
|
+
if args:
|
|
199
|
+
return args[0]
|
|
200
|
+
return kwargs.get('endpoint_url') or kwargs.get('endpointURL')
|
|
201
|
+
|
|
202
|
+
if is_async:
|
|
203
|
+
|
|
204
|
+
@functools.wraps(original)
|
|
205
|
+
async def connect_over_cdp(*args: Any, **kwargs: Any) -> Any:
|
|
206
|
+
if not is_platform_cdp_url(_target(args, kwargs), get_platform_cdp_url()):
|
|
207
|
+
return await original(*args, **kwargs)
|
|
208
|
+
return await connect_with_retry_async(lambda: original(*args, **kwargs))
|
|
209
|
+
|
|
210
|
+
else:
|
|
211
|
+
|
|
212
|
+
@functools.wraps(original)
|
|
213
|
+
def connect_over_cdp(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
|
|
214
|
+
if not is_platform_cdp_url(_target(args, kwargs), get_platform_cdp_url()):
|
|
215
|
+
return original(*args, **kwargs)
|
|
216
|
+
return connect_with_retry_sync(lambda: original(*args, **kwargs))
|
|
217
|
+
|
|
218
|
+
setattr(connect_over_cdp, _RETRY_WRAPPED_ATTR, True)
|
|
219
|
+
try:
|
|
220
|
+
chromium.connect_over_cdp = connect_over_cdp
|
|
221
|
+
except (AttributeError, TypeError):
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def is_retry_wrapped(fn: Any) -> bool:
|
|
226
|
+
return bool(getattr(fn, _RETRY_WRAPPED_ATTR, False))
|
|
@@ -70,6 +70,7 @@ from typing import Any, List, Optional, Tuple
|
|
|
70
70
|
from urllib.parse import SplitResult, parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
|
71
71
|
|
|
72
72
|
from ._browser_locations import subdivision_to_state_name
|
|
73
|
+
from .cdp_retry import connect_with_retry_async, wrap_connect_over_cdp
|
|
73
74
|
|
|
74
75
|
logger = logging.getLogger(__name__)
|
|
75
76
|
|
|
@@ -427,7 +428,9 @@ def _apply_patch() -> None:
|
|
|
427
428
|
cdp_url = apply_proxy_info_to_cdp_url(cdp_url, proxy_info, browser_configuration)
|
|
428
429
|
|
|
429
430
|
# Remote browser infrastructure is Chromium CDP (same as Node connectOverCDP).
|
|
430
|
-
|
|
431
|
+
chromium = self._playwright.chromium
|
|
432
|
+
wrap_connect_over_cdp(chromium, True, get_cdp_url)
|
|
433
|
+
browser = await chromium.connect_over_cdp(cdp_url)
|
|
431
434
|
|
|
432
435
|
controller = PlaywrightBrowserController(
|
|
433
436
|
browser,
|
|
@@ -518,7 +521,9 @@ def _apply_camoufox_patch() -> None:
|
|
|
518
521
|
# Same as the PlaywrightBrowserPlugin patch above: the first page's
|
|
519
522
|
# proxy info / browser configuration pins the connection's location.
|
|
520
523
|
cdp_url = apply_proxy_info_to_cdp_url(cdp_url, _launch_proxy_info.get(), browser_configuration)
|
|
521
|
-
|
|
524
|
+
chromium = playwright.chromium
|
|
525
|
+
wrap_connect_over_cdp(chromium, True, get_cdp_url)
|
|
526
|
+
return await chromium.connect_over_cdp(cdp_url)
|
|
522
527
|
|
|
523
528
|
camoufox.AsyncNewBrowser = _patched_async_new_browser
|
|
524
529
|
camoufox._scrapely_cdp_patched = True
|
|
@@ -726,6 +731,10 @@ def _patch_playwright_object(playwright: Any, is_async: bool) -> Any:
|
|
|
726
731
|
chromium = getattr(playwright, 'chromium', None)
|
|
727
732
|
if chromium is None or getattr(chromium, 'connect_over_cdp', None) is None:
|
|
728
733
|
return playwright
|
|
734
|
+
# Retry transient connect failures (429/5xx/network) for the platform CDP
|
|
735
|
+
# host: covers direct chromium.connect_over_cdp(CDP_URL) calls in actor
|
|
736
|
+
# code and the redirected launches below, which call this same attribute.
|
|
737
|
+
wrap_connect_over_cdp(chromium, is_async, get_cdp_url)
|
|
729
738
|
for name in ('chromium', 'firefox', 'webkit'):
|
|
730
739
|
browser_type = getattr(playwright, name, None)
|
|
731
740
|
if browser_type is None:
|
|
@@ -845,7 +854,9 @@ def _apply_pyppeteer_patch() -> None:
|
|
|
845
854
|
'ignored: %s (the remote browser manages headless mode, binary and flags).',
|
|
846
855
|
', '.join(sorted(merged.keys())),
|
|
847
856
|
)
|
|
848
|
-
return await
|
|
857
|
+
return await connect_with_retry_async(
|
|
858
|
+
lambda: pyppeteer_launcher.connect(browserWSEndpoint=cdp_url)
|
|
859
|
+
)
|
|
849
860
|
|
|
850
861
|
pyppeteer_launcher.launch = _patched_launch
|
|
851
862
|
if hasattr(pyppeteer_mod, 'launch'):
|
|
@@ -4,6 +4,7 @@ src/scrapely/__init__.py
|
|
|
4
4
|
src/scrapely/_browser_locations.py
|
|
5
5
|
src/scrapely/actor.py
|
|
6
6
|
src/scrapely/browser_configuration.py
|
|
7
|
+
src/scrapely/cdp_retry.py
|
|
7
8
|
src/scrapely/charging.py
|
|
8
9
|
src/scrapely/charging_client.py
|
|
9
10
|
src/scrapely/configuration.py
|
|
@@ -47,6 +48,7 @@ src/scrapely_python.egg-info/top_level.txt
|
|
|
47
48
|
tests/test_api_client_retries.py
|
|
48
49
|
tests/test_apify_parity.py
|
|
49
50
|
tests/test_browser_configuration.py
|
|
51
|
+
tests/test_cdp_retry.py
|
|
50
52
|
tests/test_charging_parity.py
|
|
51
53
|
tests/test_charging_pending.py
|
|
52
54
|
tests/test_parity.py
|
|
@@ -54,4 +56,5 @@ tests/test_patch_browser_pool.py
|
|
|
54
56
|
tests/test_proxy_configuration.py
|
|
55
57
|
tests/test_run_control.py
|
|
56
58
|
tests/test_secrets.py
|
|
57
|
-
tests/test_storage.py
|
|
59
|
+
tests/test_storage.py
|
|
60
|
+
tests/test_terminal_status_message.py
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Remote-browser connects retry 429/5xx/connection failures and honor Retry-After."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, List
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from scrapely import cdp_retry
|
|
12
|
+
from scrapely.cdp_retry import (
|
|
13
|
+
classify_connect_error,
|
|
14
|
+
connect_with_retry_async,
|
|
15
|
+
connect_with_retry_sync,
|
|
16
|
+
wrap_connect_over_cdp,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
PLATFORM = 'wss://browser.example.test?scrapely_run_id=r&scrapely_user_id=u'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlaywrightError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ws_error(status: str, body: str = '') -> PlaywrightError:
|
|
27
|
+
msg = f'BrowserType.connect_over_cdp: WebSocket error: wss://browser.example.test/ {status}'
|
|
28
|
+
if body:
|
|
29
|
+
msg += f'\n{body}'
|
|
30
|
+
return PlaywrightError(msg)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(autouse=True)
|
|
34
|
+
def _fast(monkeypatch: pytest.MonkeyPatch) -> List[float]:
|
|
35
|
+
sleeps: List[float] = []
|
|
36
|
+
|
|
37
|
+
async def fake_sleep(delay: float) -> None:
|
|
38
|
+
sleeps.append(delay)
|
|
39
|
+
|
|
40
|
+
monkeypatch.setattr(cdp_retry.asyncio, 'sleep', fake_sleep)
|
|
41
|
+
monkeypatch.setattr(cdp_retry.time, 'sleep', lambda d: sleeps.append(d))
|
|
42
|
+
monkeypatch.delenv('SCRAPELY_CDP_CONNECT_ATTEMPTS', raising=False)
|
|
43
|
+
return sleeps
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize(
|
|
47
|
+
'err,retryable,status',
|
|
48
|
+
[
|
|
49
|
+
(_ws_error('429 Too Many Requests'), True, 429),
|
|
50
|
+
(_ws_error('500 Internal Server Error'), True, 500),
|
|
51
|
+
(_ws_error('503 Service Unavailable'), True, 503),
|
|
52
|
+
(PlaywrightError('Unexpected server response: 502'), True, 502),
|
|
53
|
+
(_ws_error('401 Unauthorized', 'Scrapely: run_not_active'), False, 401),
|
|
54
|
+
(_ws_error('402 Payment Required', 'Scrapely: no_credits'), False, 402),
|
|
55
|
+
(PlaywrightError('connect ECONNREFUSED 1.2.3.4:443'), True, None),
|
|
56
|
+
(PlaywrightError('BrowserType.connect_over_cdp: Timeout 25000ms exceeded.'), True, None),
|
|
57
|
+
(ConnectionResetError('reset'), True, None),
|
|
58
|
+
(ValueError('bad option'), False, None),
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
def test_classify(err: BaseException, retryable: bool, status: Any) -> None:
|
|
62
|
+
got_retryable, got_status, _ = classify_connect_error(err)
|
|
63
|
+
assert (got_retryable, got_status) == (retryable, status)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_retry_after_from_body() -> None:
|
|
67
|
+
err = _ws_error('429 Too Many Requests', 'Too many requests\nRetry-After: 7')
|
|
68
|
+
assert classify_connect_error(err) == (True, 429, 7.0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_async_retries_then_succeeds_and_honors_retry_after(_fast: List[float]) -> None:
|
|
72
|
+
calls = 0
|
|
73
|
+
|
|
74
|
+
async def connect() -> str:
|
|
75
|
+
nonlocal calls
|
|
76
|
+
calls += 1
|
|
77
|
+
if calls == 1:
|
|
78
|
+
raise _ws_error('429 Too Many Requests', 'Retry-After: 9')
|
|
79
|
+
if calls == 2:
|
|
80
|
+
raise _ws_error('500 Internal Server Error')
|
|
81
|
+
return 'browser'
|
|
82
|
+
|
|
83
|
+
assert await connect_with_retry_async(connect) == 'browser'
|
|
84
|
+
assert calls == 3
|
|
85
|
+
assert _fast[0] >= 9
|
|
86
|
+
assert len(_fast) == 2
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def test_async_does_not_retry_auth_failure(_fast: List[float]) -> None:
|
|
90
|
+
calls = 0
|
|
91
|
+
|
|
92
|
+
async def connect() -> str:
|
|
93
|
+
nonlocal calls
|
|
94
|
+
calls += 1
|
|
95
|
+
raise _ws_error('401 Unauthorized')
|
|
96
|
+
|
|
97
|
+
with pytest.raises(PlaywrightError):
|
|
98
|
+
await connect_with_retry_async(connect)
|
|
99
|
+
assert calls == 1
|
|
100
|
+
assert _fast == []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def test_async_gives_up_after_max_attempts(monkeypatch: pytest.MonkeyPatch, _fast: List[float]) -> None:
|
|
104
|
+
monkeypatch.setenv('SCRAPELY_CDP_CONNECT_ATTEMPTS', '3')
|
|
105
|
+
calls = 0
|
|
106
|
+
|
|
107
|
+
async def connect() -> str:
|
|
108
|
+
nonlocal calls
|
|
109
|
+
calls += 1
|
|
110
|
+
raise _ws_error('503 Service Unavailable')
|
|
111
|
+
|
|
112
|
+
with pytest.raises(PlaywrightError):
|
|
113
|
+
await connect_with_retry_async(connect)
|
|
114
|
+
assert calls == 3
|
|
115
|
+
assert len(_fast) == 2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_sync_retries() -> None:
|
|
119
|
+
calls = 0
|
|
120
|
+
|
|
121
|
+
def connect() -> str:
|
|
122
|
+
nonlocal calls
|
|
123
|
+
calls += 1
|
|
124
|
+
if calls < 3:
|
|
125
|
+
raise ConnectionRefusedError('refused')
|
|
126
|
+
return 'browser'
|
|
127
|
+
|
|
128
|
+
assert connect_with_retry_sync(connect) == 'browser'
|
|
129
|
+
assert calls == 3
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _Chromium:
|
|
133
|
+
def __init__(self, failures: int) -> None:
|
|
134
|
+
self.failures = failures
|
|
135
|
+
self.urls: List[str] = []
|
|
136
|
+
|
|
137
|
+
async def connect_over_cdp(self, endpoint_url: str, **kwargs: Any) -> str:
|
|
138
|
+
self.urls.append(endpoint_url)
|
|
139
|
+
if len(self.urls) <= self.failures:
|
|
140
|
+
raise _ws_error('429 Too Many Requests')
|
|
141
|
+
return 'browser'
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def test_wrapped_connect_over_cdp_retries_platform_host_only() -> None:
|
|
145
|
+
chromium = _Chromium(failures=2)
|
|
146
|
+
wrap_connect_over_cdp(chromium, True, lambda: PLATFORM)
|
|
147
|
+
wrap_connect_over_cdp(chromium, True, lambda: PLATFORM) # idempotent
|
|
148
|
+
assert await chromium.connect_over_cdp(PLATFORM + '&proxy_country=US') == 'browser'
|
|
149
|
+
assert len(chromium.urls) == 3
|
|
150
|
+
|
|
151
|
+
other = _Chromium(failures=1)
|
|
152
|
+
wrap_connect_over_cdp(other, True, lambda: PLATFORM)
|
|
153
|
+
with pytest.raises(PlaywrightError):
|
|
154
|
+
await other.connect_over_cdp('ws://localhost:9222/devtools/browser/x')
|
|
155
|
+
assert len(other.urls) == 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_long_retry_after_ends_retries_instead_of_retrying_early(_fast: List[float]) -> None:
|
|
159
|
+
calls = 0
|
|
160
|
+
|
|
161
|
+
async def connect() -> str:
|
|
162
|
+
nonlocal calls
|
|
163
|
+
calls += 1
|
|
164
|
+
raise _ws_error('429 Too Many Requests', 'Retry-After: 600')
|
|
165
|
+
|
|
166
|
+
with pytest.raises(PlaywrightError):
|
|
167
|
+
await connect_with_retry_async(connect)
|
|
168
|
+
assert calls == 1
|
|
169
|
+
assert _fast == []
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Actor.exit()/fail() store their status message on the run as terminal, before teardown."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from scrapely import Actor
|
|
11
|
+
from scrapely.configuration import Configuration
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _FakeResponse:
|
|
15
|
+
def __init__(self, data: Dict[str, Any]) -> None:
|
|
16
|
+
self._data = data
|
|
17
|
+
self.content = b'x'
|
|
18
|
+
|
|
19
|
+
def json(self) -> Dict[str, Any]:
|
|
20
|
+
return {'data': self._data}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FakeClient:
|
|
24
|
+
def __init__(self, events: List[Tuple[str, Any]], fail: bool = False, hang: bool = False) -> None:
|
|
25
|
+
self.events = events
|
|
26
|
+
self.fail = fail
|
|
27
|
+
self.hang = hang
|
|
28
|
+
|
|
29
|
+
async def put(self, path: str, json_data: Optional[Dict[str, Any]] = None) -> _FakeResponse:
|
|
30
|
+
if self.hang:
|
|
31
|
+
await asyncio.sleep(60)
|
|
32
|
+
if self.fail:
|
|
33
|
+
raise RuntimeError('API down')
|
|
34
|
+
self.events.append(('put', (path, json_data)))
|
|
35
|
+
return _FakeResponse({'id': 'run123', **(json_data or {})})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def lifecycle(monkeypatch: pytest.MonkeyPatch):
|
|
40
|
+
events: List[Tuple[str, Any]] = []
|
|
41
|
+
exit_codes: List[int] = []
|
|
42
|
+
state = {
|
|
43
|
+
'_exit_code': Actor._exit_code,
|
|
44
|
+
'_status_message': Actor._status_message,
|
|
45
|
+
'_initialized': Actor._initialized,
|
|
46
|
+
'_config': Actor._config,
|
|
47
|
+
}
|
|
48
|
+
config = Configuration({'api_base_url': 'http://api.test', 'token': 'tok', 'actor_run_id': 'run123'})
|
|
49
|
+
Actor._config = config
|
|
50
|
+
Actor._initialized = True
|
|
51
|
+
|
|
52
|
+
async def fake_shutdown(timeout_secs: int = 30) -> None:
|
|
53
|
+
events.append(('shutdown', timeout_secs))
|
|
54
|
+
Actor._initialized = False
|
|
55
|
+
|
|
56
|
+
client = _FakeClient(events)
|
|
57
|
+
monkeypatch.setattr(Actor, '_shutdown', fake_shutdown)
|
|
58
|
+
monkeypatch.setattr(Actor, 'new_client', classmethod(lambda cls, token=None, base_url=None: client))
|
|
59
|
+
monkeypatch.setattr('sys.exit', lambda code=0: exit_codes.append(code))
|
|
60
|
+
try:
|
|
61
|
+
yield events, exit_codes, client
|
|
62
|
+
finally:
|
|
63
|
+
for key, value in state.items():
|
|
64
|
+
setattr(Actor, key, value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def test_fail_stores_terminal_status_message_before_shutdown(lifecycle) -> None:
|
|
68
|
+
events, exit_codes, _ = lifecycle
|
|
69
|
+
await Actor.fail(status_message='No results: bad input')
|
|
70
|
+
assert events[0] == (
|
|
71
|
+
'put',
|
|
72
|
+
('/v2/actor-runs/run123', {'statusMessage': 'No results: bad input', 'isStatusMessageTerminal': True}),
|
|
73
|
+
)
|
|
74
|
+
assert events[1][0] == 'shutdown'
|
|
75
|
+
assert exit_codes == [1]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_exit_stores_terminal_status_message(lifecycle) -> None:
|
|
79
|
+
events, exit_codes, _ = lifecycle
|
|
80
|
+
await Actor.exit('Done: 10 items')
|
|
81
|
+
assert events[0] == (
|
|
82
|
+
'put',
|
|
83
|
+
('/v2/actor-runs/run123', {'statusMessage': 'Done: 10 items', 'isStatusMessageTerminal': True}),
|
|
84
|
+
)
|
|
85
|
+
assert exit_codes == [0]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def test_exit_without_message_sends_nothing(lifecycle) -> None:
|
|
89
|
+
events, _, _ = lifecycle
|
|
90
|
+
await Actor.exit(exit_code=1)
|
|
91
|
+
assert [e[0] for e in events] == ['shutdown']
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def test_failed_update_does_not_block_exit(lifecycle) -> None:
|
|
95
|
+
events, exit_codes, client = lifecycle
|
|
96
|
+
client.fail = True
|
|
97
|
+
await Actor.fail(status_message='boom')
|
|
98
|
+
assert [e[0] for e in events] == ['shutdown']
|
|
99
|
+
assert exit_codes == [1]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def test_hanging_update_is_bounded(lifecycle, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
103
|
+
events, exit_codes, client = lifecycle
|
|
104
|
+
client.hang = True
|
|
105
|
+
original = Actor._persist_terminal_status_message.__func__
|
|
106
|
+
|
|
107
|
+
async def quick(cls, message, timeout_secs: float = 10.0):
|
|
108
|
+
await original(cls, message, timeout_secs=0.01)
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(Actor, '_persist_terminal_status_message', classmethod(quick))
|
|
111
|
+
await asyncio.wait_for(Actor.fail(status_message='boom'), timeout=5)
|
|
112
|
+
assert [e[0] for e in events] == ['shutdown']
|
|
113
|
+
assert exit_codes == [1]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def test_context_manager_status_message_is_terminal(lifecycle, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
117
|
+
events, _, _ = lifecycle
|
|
118
|
+
|
|
119
|
+
async def fake_init(options: Any = None) -> None:
|
|
120
|
+
Actor._initialized = True
|
|
121
|
+
|
|
122
|
+
monkeypatch.setattr(Actor, 'init', fake_init)
|
|
123
|
+
with pytest.raises(ValueError):
|
|
124
|
+
async with Actor(status_message='Crashed while parsing'):
|
|
125
|
+
raise ValueError('parse error')
|
|
126
|
+
assert events[0] == (
|
|
127
|
+
'put',
|
|
128
|
+
('/v2/actor-runs/run123', {'statusMessage': 'Crashed while parsing', 'isStatusMessageTerminal': True}),
|
|
129
|
+
)
|
|
130
|
+
assert events[1][0] == 'shutdown'
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/_file_system/_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely/storage/scrapely_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.5 → scrapely_python-0.1.6}/src/scrapely_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|