scrapely-python 0.1.6__tar.gz → 0.1.7__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.6 → scrapely_python-0.1.7}/PKG-INFO +1 -1
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/pyproject.toml +1 -1
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/actor.py +16 -7
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/charging.py +54 -6
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/configuration.py +21 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/proxy_configuration.py +47 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/api_client.py +20 -3
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/client.py +6 -1
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/SOURCES.txt +2 -0
- scrapely_python-0.1.7/tests/test_charge_retries.py +141 -0
- scrapely_python-0.1.7/tests/test_input_proxy_aliases.py +98 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/README.md +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/setup.cfg +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_api_client_retries.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_cdp_retry.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_storage.py +0 -0
- {scrapely_python-0.1.6 → scrapely_python-0.1.7}/tests/test_terminal_status_message.py +0 -0
|
@@ -27,6 +27,7 @@ from crawlee.storages import Dataset, KeyValueStore, RequestQueue
|
|
|
27
27
|
from .charging import ChargingManager, is_default_dataset_item_event, push_data_and_charge
|
|
28
28
|
from .configuration import Configuration
|
|
29
29
|
from .crypto import decrypt_secret_inputs
|
|
30
|
+
from .proxy_configuration import with_proxy_key_aliases
|
|
30
31
|
from crawlee.events import EventManager
|
|
31
32
|
|
|
32
33
|
from .events import Event, LocalEventManager, ScrapelyEventManager
|
|
@@ -484,8 +485,10 @@ class Actor(metaclass=_ActorMeta):
|
|
|
484
485
|
flush = getattr(cls._charging_manager, 'flush_pending', None)
|
|
485
486
|
if flush is not None:
|
|
486
487
|
deadline_ms = min(max(1, timeout_secs) * 1000, 30_000)
|
|
488
|
+
# The flush's own deadline ends its retries (charge POSTs back off
|
|
489
|
+
# for up to ~45 s) a little before wait_for would cancel it.
|
|
487
490
|
await asyncio.wait_for(
|
|
488
|
-
flush(max_intents=64, deadline_ms=deadline_ms),
|
|
491
|
+
flush(max_intents=64, deadline_ms=max(500, deadline_ms - 2_000)),
|
|
489
492
|
timeout=max(1.0, deadline_ms / 1000.0),
|
|
490
493
|
)
|
|
491
494
|
except BaseException as e:
|
|
@@ -687,8 +690,8 @@ class Actor(metaclass=_ActorMeta):
|
|
|
687
690
|
kvs = await cls.open_key_value_store()
|
|
688
691
|
metamorph_input = await kvs.get_value(f'INPUT-METAMORPH-{count}')
|
|
689
692
|
if metamorph_input is not None:
|
|
690
|
-
cls._cached_input = metamorph_input
|
|
691
|
-
return cast(Dict[str, Any],
|
|
693
|
+
cls._cached_input = with_proxy_key_aliases(metamorph_input)
|
|
694
|
+
return cast(Dict[str, Any], cls._cached_input)
|
|
692
695
|
|
|
693
696
|
raw_input = os.environ.get('ACTOR_INPUT')
|
|
694
697
|
if raw_input:
|
|
@@ -701,7 +704,8 @@ class Actor(metaclass=_ActorMeta):
|
|
|
701
704
|
# path is decrypted, mirroring Actor.getInput() in the Node SDK)
|
|
702
705
|
if isinstance(parsed, dict):
|
|
703
706
|
parsed = decrypt_secret_inputs(parsed)
|
|
704
|
-
|
|
707
|
+
# Proxy objects carry both namings (scrapelyProxy* and apifyProxy*).
|
|
708
|
+
cls._cached_input = with_proxy_key_aliases(parsed)
|
|
705
709
|
return cast(Dict[str, Any], cls._cached_input)
|
|
706
710
|
|
|
707
711
|
kvs = await cls.open_key_value_store()
|
|
@@ -709,8 +713,8 @@ class Actor(metaclass=_ActorMeta):
|
|
|
709
713
|
result = await kvs.get_value(input_key)
|
|
710
714
|
if isinstance(result, dict):
|
|
711
715
|
result = decrypt_secret_inputs(result)
|
|
712
|
-
cls._cached_input = result
|
|
713
|
-
return cast(Optional[Dict[str, Any]],
|
|
716
|
+
cls._cached_input = with_proxy_key_aliases(result)
|
|
717
|
+
return cast(Optional[Dict[str, Any]], cls._cached_input)
|
|
714
718
|
|
|
715
719
|
@classmethod
|
|
716
720
|
async def get_input_or_throw(cls) -> Dict[str, Any]:
|
|
@@ -739,7 +743,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
739
743
|
|
|
740
744
|
# Decrypt secret inputs after loading (JS parity with Actor.input getter)
|
|
741
745
|
if isinstance(cls._cached_input, dict):
|
|
742
|
-
cls._cached_input = decrypt_secret_inputs(cls._cached_input)
|
|
746
|
+
cls._cached_input = with_proxy_key_aliases(decrypt_secret_inputs(cls._cached_input))
|
|
743
747
|
|
|
744
748
|
return cls._cached_input
|
|
745
749
|
|
|
@@ -1172,6 +1176,11 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1172
1176
|
"""Return the Configuration instance."""
|
|
1173
1177
|
return cls.get_config()
|
|
1174
1178
|
|
|
1179
|
+
@_ClassProperty
|
|
1180
|
+
def config(cls) -> Configuration:
|
|
1181
|
+
"""Return the Configuration instance (Apify SDK ``Actor.config`` parity)."""
|
|
1182
|
+
return cls.get_config()
|
|
1183
|
+
|
|
1175
1184
|
@_ClassProperty
|
|
1176
1185
|
def event_manager(cls) -> Optional[EventManager]:
|
|
1177
1186
|
"""Return the EventManager instance."""
|
|
@@ -47,8 +47,16 @@ CHARGE_IDEMPOTENCY_SAFE_AGE_SECS = 30 * 24 * 60 * 60
|
|
|
47
47
|
# Soft cap on open unresolved intents. Refuse NEW billable charges when full.
|
|
48
48
|
_PENDING_MAX_ENTRIES = 1024
|
|
49
49
|
|
|
50
|
-
#
|
|
51
|
-
|
|
50
|
+
# Same-key POST attempts before leaving an intent pending / raising. The API
|
|
51
|
+
# client itself retries 5xx answers and refused or reset connections of these
|
|
52
|
+
# POSTs with its long backoff (0.5 s doubling to 8 s, about 45 s): re-sending
|
|
53
|
+
# is safe because every attempt carries the same Idempotency-Key. These outer
|
|
54
|
+
# attempts cover what the client gives up on quickly (timeouts, other
|
|
55
|
+
# transport errors), with the same backoff, within _RETRY_BUDGET_SECS.
|
|
56
|
+
_POST_ATTEMPTS = 8
|
|
57
|
+
|
|
58
|
+
# Outer retries stop once this much time has passed since the first attempt.
|
|
59
|
+
_RETRY_BUDGET_SECS = 60.0
|
|
52
60
|
|
|
53
61
|
# Local-only dataset collecting one entry per logical charge operation
|
|
54
62
|
# (JS ChargingManager LOCAL_CHARGING_LOG_DATASET_NAME parity).
|
|
@@ -160,6 +168,7 @@ class ChargingManager:
|
|
|
160
168
|
_PENDING_MAX_ENTRIES = _PENDING_MAX_ENTRIES
|
|
161
169
|
_IDEMPOTENCY_SAFE_AGE_SECS = CHARGE_IDEMPOTENCY_SAFE_AGE_SECS
|
|
162
170
|
_POST_ATTEMPTS = _POST_ATTEMPTS
|
|
171
|
+
_RETRY_BUDGET_SECS = _RETRY_BUDGET_SECS
|
|
163
172
|
|
|
164
173
|
def __init__(self, config: 'Configuration', client: 'ApiClient'):
|
|
165
174
|
"""Initialize the charging manager.
|
|
@@ -458,12 +467,14 @@ class ChargingManager:
|
|
|
458
467
|
"""
|
|
459
468
|
if self._client is None or not self._is_at_home or not self._actor_run_id:
|
|
460
469
|
return False
|
|
470
|
+
started = time.monotonic()
|
|
461
471
|
for attempt in range(self._POST_ATTEMPTS):
|
|
462
472
|
try:
|
|
463
473
|
await self._client.post(
|
|
464
474
|
f'/v2/actor-runs/{self._actor_run_id}/charge/void',
|
|
465
475
|
json_data={},
|
|
466
476
|
headers={'Idempotency-Key': operation_id},
|
|
477
|
+
idempotent=True,
|
|
467
478
|
)
|
|
468
479
|
self._unwind_reservation(event_name, reserved)
|
|
469
480
|
return True
|
|
@@ -478,8 +489,8 @@ class ChargingManager:
|
|
|
478
489
|
# 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
479
490
|
if status is not None and 400 <= status < 500:
|
|
480
491
|
return False
|
|
481
|
-
if
|
|
482
|
-
|
|
492
|
+
if not await self._backoff_before_retry(attempt, started, err):
|
|
493
|
+
break
|
|
483
494
|
# Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
484
495
|
self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
|
|
485
496
|
logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
|
|
@@ -528,6 +539,7 @@ class ChargingManager:
|
|
|
528
539
|
event_name: str,
|
|
529
540
|
count: int,
|
|
530
541
|
idempotency_key: str,
|
|
542
|
+
deadline: Optional[float] = None,
|
|
531
543
|
) -> Dict[str, Any]:
|
|
532
544
|
if self._client is None:
|
|
533
545
|
raise RuntimeError('ChargingManager has no API client; cannot post charge intent')
|
|
@@ -538,6 +550,8 @@ class ChargingManager:
|
|
|
538
550
|
'count': count,
|
|
539
551
|
},
|
|
540
552
|
headers={'Idempotency-Key': idempotency_key},
|
|
553
|
+
idempotent=True,
|
|
554
|
+
**({'retry_deadline': deadline} if deadline is not None else {}),
|
|
541
555
|
)
|
|
542
556
|
if isinstance(response, dict):
|
|
543
557
|
return response
|
|
@@ -550,25 +564,58 @@ class ChargingManager:
|
|
|
550
564
|
pass
|
|
551
565
|
return {}
|
|
552
566
|
|
|
567
|
+
async def _backoff_before_retry(
|
|
568
|
+
self, attempt: int, started: float, err: BaseException, deadline: Optional[float] = None
|
|
569
|
+
) -> bool:
|
|
570
|
+
"""Sleep before the next same-key attempt; False when no attempt is left.
|
|
571
|
+
|
|
572
|
+
A 5xx answer reaching this point was already retried by the API client
|
|
573
|
+
with its long backoff, so it ends the attempts. Timeouts and other
|
|
574
|
+
errors without an answer back off the same way (0.5 s doubling to 8 s)
|
|
575
|
+
while attempts and the retry budget last.
|
|
576
|
+
"""
|
|
577
|
+
from .storage.api_client import server_retry_delay
|
|
578
|
+
|
|
579
|
+
if attempt + 1 >= self._POST_ATTEMPTS:
|
|
580
|
+
return False
|
|
581
|
+
status = _http_status_of(err)
|
|
582
|
+
if status is not None and status >= 500:
|
|
583
|
+
return False
|
|
584
|
+
delay = server_retry_delay(attempt, None)
|
|
585
|
+
if time.monotonic() - started + delay > self._RETRY_BUDGET_SECS:
|
|
586
|
+
return False
|
|
587
|
+
if deadline is not None and time.monotonic() + delay > deadline:
|
|
588
|
+
return False
|
|
589
|
+
logger.warning('Charge request failed (%s); retrying in %.1fs', type(err).__name__, delay)
|
|
590
|
+
await asyncio.sleep(delay)
|
|
591
|
+
return True
|
|
592
|
+
|
|
553
593
|
async def _post_charge_with_retries(
|
|
554
594
|
self,
|
|
555
595
|
*,
|
|
556
596
|
event_name: str,
|
|
557
597
|
count: int,
|
|
558
598
|
idempotency_key: str,
|
|
599
|
+
deadline: Optional[float] = None,
|
|
559
600
|
) -> Optional[int]:
|
|
560
601
|
"""POST charge; return acceptedCount on success, None if uncertain.
|
|
561
602
|
|
|
603
|
+
``deadline`` (``time.monotonic()``) bounds every retry, the API
|
|
604
|
+
client's included, so a flush with a time budget ends with the intent
|
|
605
|
+
still pending instead of being cancelled mid-request.
|
|
606
|
+
|
|
562
607
|
Raises ChargeRejectedError for definitive platform rejections.
|
|
563
608
|
Same idempotency key on every attempt.
|
|
564
609
|
"""
|
|
565
610
|
last_error: Optional[BaseException] = None
|
|
611
|
+
started = time.monotonic()
|
|
566
612
|
for attempt in range(self._POST_ATTEMPTS):
|
|
567
613
|
try:
|
|
568
614
|
body = await self._post_charge_once(
|
|
569
615
|
event_name=event_name,
|
|
570
616
|
count=count,
|
|
571
617
|
idempotency_key=idempotency_key,
|
|
618
|
+
deadline=deadline,
|
|
572
619
|
)
|
|
573
620
|
return _read_accepted_count(body, count)
|
|
574
621
|
except Exception as e:
|
|
@@ -581,8 +628,8 @@ class ChargingManager:
|
|
|
581
628
|
)
|
|
582
629
|
if rejected is not None:
|
|
583
630
|
raise rejected from e
|
|
584
|
-
if attempt
|
|
585
|
-
|
|
631
|
+
if not await self._backoff_before_retry(attempt, started, e, deadline):
|
|
632
|
+
break
|
|
586
633
|
if last_error is not None:
|
|
587
634
|
logger.warning(
|
|
588
635
|
'Failed to record charge for event %s (count=%s, key=%s): %s',
|
|
@@ -638,6 +685,7 @@ class ChargingManager:
|
|
|
638
685
|
event_name=event_name,
|
|
639
686
|
count=count,
|
|
640
687
|
idempotency_key=idempotency_key,
|
|
688
|
+
deadline=deadline,
|
|
641
689
|
)
|
|
642
690
|
except ChargeRejectedError as err:
|
|
643
691
|
self._unwind_reservation(event_name, count)
|
|
@@ -145,6 +145,8 @@ class Configuration:
|
|
|
145
145
|
'proxy_hostname': 'proxy.scrape.ly',
|
|
146
146
|
'proxy_port': 8000,
|
|
147
147
|
'container_port': 4321,
|
|
148
|
+
# Apify parity: standby runs listen on ACTOR_STANDBY_PORT (4321 unless set).
|
|
149
|
+
'standby_port': 4321,
|
|
148
150
|
'container_url': 'http://localhost:4321',
|
|
149
151
|
'metamorph_after_sleep_millis': 300000,
|
|
150
152
|
'persist_state_interval_millis': 60000,
|
|
@@ -245,6 +247,25 @@ class Configuration:
|
|
|
245
247
|
"""
|
|
246
248
|
self._config[key] = value
|
|
247
249
|
|
|
250
|
+
def __getattr__(self, name: str) -> Any:
|
|
251
|
+
"""Attribute access to any known setting (Apify ``Actor.config.<field>`` parity).
|
|
252
|
+
|
|
253
|
+
``Actor.config.standby_port``, ``Actor.config.meta_origin`` and the other
|
|
254
|
+
keys this class reads from the environment resolve like ``get(name)``.
|
|
255
|
+
"""
|
|
256
|
+
if name.startswith('_'):
|
|
257
|
+
raise AttributeError(name)
|
|
258
|
+
known = name in self.DEFAULTS or name in self.ENV_MAP.values() or name in self.__dict__.get('_config', {})
|
|
259
|
+
if not known:
|
|
260
|
+
raise AttributeError(f'{type(self).__name__!r} object has no attribute {name!r}')
|
|
261
|
+
return self.get(name)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def standby_port(self) -> int:
|
|
265
|
+
"""Port a standby run serves HTTP on (ACTOR_STANDBY_PORT, default 4321)."""
|
|
266
|
+
value = self.get('standby_port')
|
|
267
|
+
return value if isinstance(value, int) else 4321
|
|
268
|
+
|
|
248
269
|
@property
|
|
249
270
|
def token(self) -> Optional[str]:
|
|
250
271
|
"""Get the authentication token."""
|
|
@@ -127,6 +127,53 @@ def _input_proxy_option(actor_proxy_input: Dict[str, Any], name: str) -> Any:
|
|
|
127
127
|
return value if value is not None else actor_proxy_input.get(f'apifyProxy{name}')
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
_PROXY_OPTION_NAMES = ('Groups', 'Country', 'Subdivision')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_proxy_input_object(value: Any) -> bool:
|
|
134
|
+
if not isinstance(value, dict):
|
|
135
|
+
return False
|
|
136
|
+
keys = ['useScrapelyProxy', 'useApifyProxy']
|
|
137
|
+
for name in _PROXY_OPTION_NAMES:
|
|
138
|
+
keys += [f'scrapelyProxy{name}', f'apifyProxy{name}']
|
|
139
|
+
return any(key in value for key in keys)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def with_proxy_key_aliases(actor_input: Any) -> Any:
|
|
143
|
+
"""Actor input with every proxy option under both its Scrapely and Apify name.
|
|
144
|
+
|
|
145
|
+
For each top-level object that has a proxy key of either naming
|
|
146
|
+
(``useScrapelyProxy`` / ``useApifyProxy``, ``scrapelyProxyGroups`` /
|
|
147
|
+
``apifyProxyGroups``, ``...Country``, ``...Subdivision``), every set value
|
|
148
|
+
is written under both names, so code that reads either naming sees the
|
|
149
|
+
user's choice. A boolean ``useScrapelyProxy`` and a set ``scrapelyProxy*``
|
|
150
|
+
value win over their Apify counterparts. Nothing is removed: keys with
|
|
151
|
+
unset or non-boolean values stay as sent. Returns ``actor_input`` itself
|
|
152
|
+
when nothing changes; never mutates it.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(actor_input, dict):
|
|
155
|
+
return actor_input
|
|
156
|
+
result: Optional[Dict[str, Any]] = None
|
|
157
|
+
for key, value in actor_input.items():
|
|
158
|
+
if not _is_proxy_input_object(value):
|
|
159
|
+
continue
|
|
160
|
+
aliased = dict(value)
|
|
161
|
+
use = _input_use_platform_proxy(value)
|
|
162
|
+
if use is not None:
|
|
163
|
+
aliased['useScrapelyProxy'] = use
|
|
164
|
+
aliased['useApifyProxy'] = use
|
|
165
|
+
for name in _PROXY_OPTION_NAMES:
|
|
166
|
+
option = _input_proxy_option(value, name)
|
|
167
|
+
if option is not None:
|
|
168
|
+
aliased[f'scrapelyProxy{name}'] = option
|
|
169
|
+
aliased[f'apifyProxy{name}'] = option
|
|
170
|
+
if aliased != value:
|
|
171
|
+
if result is None:
|
|
172
|
+
result = dict(actor_input)
|
|
173
|
+
result[key] = aliased
|
|
174
|
+
return result if result is not None else actor_input
|
|
175
|
+
|
|
176
|
+
|
|
130
177
|
def _platform_env() -> Optional[Dict[str, Any]]:
|
|
131
178
|
"""Read platform proxy credentials from the environment (or None)."""
|
|
132
179
|
hostname = os.environ.get('SCRAPELY_PROXY_HOSTNAME') or os.environ.get('APIFY_PROXY_HOSTNAME')
|
|
@@ -258,8 +258,15 @@ class ApiClient:
|
|
|
258
258
|
raise_for_status: bool = True,
|
|
259
259
|
do_not_retry_timeouts: bool = False,
|
|
260
260
|
idempotent: Optional[bool] = None,
|
|
261
|
+
retry_deadline: Optional[float] = None,
|
|
261
262
|
) -> httpx.Response:
|
|
262
|
-
"""Make an HTTP request with adaptive rate limiting and 429 retries.
|
|
263
|
+
"""Make an HTTP request with adaptive rate limiting and 429 retries.
|
|
264
|
+
|
|
265
|
+
``retry_deadline`` (a ``time.monotonic()`` value) stops the 5xx /
|
|
266
|
+
connection retries once the next backoff would end after it, so a
|
|
267
|
+
caller with a time budget (e.g. the exit flush) gets an answer instead
|
|
268
|
+
of being cancelled mid-retry.
|
|
269
|
+
"""
|
|
263
270
|
client = await self._ensure_client()
|
|
264
271
|
url = self._url(path)
|
|
265
272
|
logger.debug('%s %s params=%s', method, url, params)
|
|
@@ -293,8 +300,12 @@ class ApiClient:
|
|
|
293
300
|
# No response. A refused/unresolvable connection never reached
|
|
294
301
|
# the API, so it is safe for every method; anything else (reset
|
|
295
302
|
# mid-request) only for idempotent ones.
|
|
296
|
-
|
|
297
|
-
|
|
303
|
+
wait = server_retry_delay(server_attempt, None)
|
|
304
|
+
if (
|
|
305
|
+
(idempotent or isinstance(exc, httpx.ConnectError))
|
|
306
|
+
and server_attempt < self._max_server_retries
|
|
307
|
+
and (retry_deadline is None or time.monotonic() + wait <= retry_deadline)
|
|
308
|
+
):
|
|
298
309
|
server_attempt += 1
|
|
299
310
|
logger.warning(
|
|
300
311
|
'%s %s failed (%s); retry %d/%d in %.1fs',
|
|
@@ -316,6 +327,10 @@ class ApiClient:
|
|
|
316
327
|
if (
|
|
317
328
|
not response.is_success
|
|
318
329
|
and server_attempt < self._max_server_retries
|
|
330
|
+
and (
|
|
331
|
+
retry_deadline is None
|
|
332
|
+
or time.monotonic() + server_retry_delay(server_attempt, response.headers.get('Retry-After')) <= retry_deadline
|
|
333
|
+
)
|
|
319
334
|
and is_retryable_response(
|
|
320
335
|
response.status_code,
|
|
321
336
|
idempotent,
|
|
@@ -364,6 +379,7 @@ class ApiClient:
|
|
|
364
379
|
*,
|
|
365
380
|
raise_for_status: bool = True,
|
|
366
381
|
idempotent: Optional[bool] = None,
|
|
382
|
+
retry_deadline: Optional[float] = None,
|
|
367
383
|
) -> httpx.Response:
|
|
368
384
|
return await self.request(
|
|
369
385
|
'POST',
|
|
@@ -374,6 +390,7 @@ class ApiClient:
|
|
|
374
390
|
headers=headers,
|
|
375
391
|
raise_for_status=raise_for_status,
|
|
376
392
|
idempotent=idempotent,
|
|
393
|
+
retry_deadline=retry_deadline,
|
|
377
394
|
)
|
|
378
395
|
|
|
379
396
|
async def put(
|
|
@@ -293,8 +293,13 @@ class ApiStorageClient:
|
|
|
293
293
|
value: Any,
|
|
294
294
|
content_type: Optional[str] = None,
|
|
295
295
|
) -> None:
|
|
296
|
+
if isinstance(value, (bytearray, memoryview)):
|
|
297
|
+
value = bytes(value)
|
|
296
298
|
if content_type is None:
|
|
297
|
-
|
|
299
|
+
# Same defaults as Apify: bytes are binary, str is text, anything else JSON.
|
|
300
|
+
if isinstance(value, bytes):
|
|
301
|
+
content_type = 'application/octet-stream'
|
|
302
|
+
elif isinstance(value, str):
|
|
298
303
|
content_type = 'text/plain'
|
|
299
304
|
else:
|
|
300
305
|
content_type = 'application/json'
|
|
@@ -49,8 +49,10 @@ tests/test_api_client_retries.py
|
|
|
49
49
|
tests/test_apify_parity.py
|
|
50
50
|
tests/test_browser_configuration.py
|
|
51
51
|
tests/test_cdp_retry.py
|
|
52
|
+
tests/test_charge_retries.py
|
|
52
53
|
tests/test_charging_parity.py
|
|
53
54
|
tests/test_charging_pending.py
|
|
55
|
+
tests/test_input_proxy_aliases.py
|
|
54
56
|
tests/test_parity.py
|
|
55
57
|
tests/test_patch_browser_pool.py
|
|
56
58
|
tests/test_proxy_configuration.py
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Charge and void POSTs ride out 5xx answers and timeouts with the long backoff.
|
|
2
|
+
|
|
3
|
+
Staging 2026-10-14: about 3 of 40 pay-per-event runs logged "Failed to record
|
|
4
|
+
charge" three times within 1.5 s and raised ChargeUncertainError, because the
|
|
5
|
+
charge POST retried 3 times with 0.25 s / 0.5 s sleeps and the API client did
|
|
6
|
+
not retry 500/502/504 for POST. Every attempt carries the same
|
|
7
|
+
Idempotency-Key, so re-sending is safe: the client now retries any 5xx (and
|
|
8
|
+
reset connections) with its 0.5 s → 8 s backoff, and timeouts get the same
|
|
9
|
+
backoff from the charging manager.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import List
|
|
16
|
+
from unittest.mock import MagicMock
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from scrapely import charging as charging_mod
|
|
22
|
+
from scrapely.charging import ChargeRejectedError, ChargingManager
|
|
23
|
+
from scrapely.pricing_types import PayPerEventActorPricingInfo, PricingPerEvent
|
|
24
|
+
from scrapely.storage import api_client as client_mod
|
|
25
|
+
from scrapely.storage.api_client import ApiClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def no_sleep(monkeypatch: pytest.MonkeyPatch) -> List[float]:
|
|
30
|
+
slept: List[float] = []
|
|
31
|
+
|
|
32
|
+
async def fake_sleep(secs: float) -> None:
|
|
33
|
+
slept.append(secs)
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(client_mod.asyncio, 'sleep', fake_sleep)
|
|
36
|
+
monkeypatch.setattr(charging_mod.asyncio, 'sleep', fake_sleep)
|
|
37
|
+
return slept
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def manager_for(responses: list) -> tuple[ChargingManager, list]:
|
|
41
|
+
calls: list = []
|
|
42
|
+
|
|
43
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
44
|
+
calls.append((request.url.path, request.headers.get('Idempotency-Key')))
|
|
45
|
+
r = responses.pop(0) if len(responses) > 1 else responses[0]
|
|
46
|
+
if isinstance(r, Exception):
|
|
47
|
+
raise r
|
|
48
|
+
return r
|
|
49
|
+
|
|
50
|
+
client = ApiClient('http://api.test', 'st_x', 'run-1', transport=httpx.MockTransport(handler))
|
|
51
|
+
config = MagicMock()
|
|
52
|
+
config.get = MagicMock(side_effect=lambda key, default=None: {
|
|
53
|
+
'is_at_home': True, 'actor_run_id': 'run-1', 'max_total_charge_usd': '100',
|
|
54
|
+
}.get(key, default))
|
|
55
|
+
mgr = ChargingManager(config, client)
|
|
56
|
+
mgr._pricing_info = PayPerEventActorPricingInfo(pricing_per_event={'page-open': PricingPerEvent(event_price_usd=Decimal('0.01'))})
|
|
57
|
+
mgr._per_event_prices = {'page-open': Decimal('0.01')}
|
|
58
|
+
mgr._initialized = True
|
|
59
|
+
mgr._actor_run_id = 'run-1'
|
|
60
|
+
mgr._max_total_charge_usd = Decimal('100')
|
|
61
|
+
return mgr, calls
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ok(count: int = 1) -> httpx.Response:
|
|
65
|
+
return httpx.Response(201, json={'acceptedCount': count, 'requestedCount': count})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize('status', [500, 502, 503, 504])
|
|
69
|
+
async def test_charge_5xx_is_retried_with_the_same_key(status: int, no_sleep: List[float]) -> None:
|
|
70
|
+
mgr, calls = manager_for([httpx.Response(status, json={'error': {'type': 'internal-error'}})] * 5 + [ok(2)])
|
|
71
|
+
result = await mgr.charge('page-open', 2)
|
|
72
|
+
assert result.charged_count == 2
|
|
73
|
+
assert len(calls) == 6 and len({key for _, key in calls}) == 1, 'one operation, one Idempotency-Key'
|
|
74
|
+
assert sum(no_sleep) > 7, f'long backoff, not 1.5 s: {no_sleep}'
|
|
75
|
+
assert not mgr._pending_charge_keys
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_charge_timeouts_back_off_until_confirmed(no_sleep: List[float]) -> None:
|
|
79
|
+
timeout = httpx.ReadTimeout('timed out')
|
|
80
|
+
mgr, calls = manager_for([timeout] * 9 + [ok()])
|
|
81
|
+
result = await mgr.charge('page-open', 1)
|
|
82
|
+
assert result.charged_count == 1
|
|
83
|
+
assert len({key for _, key in calls}) == 1
|
|
84
|
+
assert any(s >= 1.0 for s in no_sleep), f'timeouts get the growing backoff too: {no_sleep}'
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_charge_reset_connection_is_retried() -> None:
|
|
88
|
+
mgr, calls = manager_for([httpx.RemoteProtocolError('reset'), ok()])
|
|
89
|
+
assert (await mgr.charge('page-open', 1)).charged_count == 1
|
|
90
|
+
assert len(calls) == 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_rejection_is_not_retried() -> None:
|
|
94
|
+
mgr, calls = manager_for([httpx.Response(402, json={'error': {'type': 'charge-limit-exceeded', 'message': 'limit'}})])
|
|
95
|
+
with pytest.raises(ChargeRejectedError):
|
|
96
|
+
await mgr._post_charge_with_retries(event_name='page-open', count=1, idempotency_key='k1')
|
|
97
|
+
assert len(calls) == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_retries_stop_within_the_budget(no_sleep: List[float]) -> None:
|
|
101
|
+
mgr, calls = manager_for([httpx.ReadTimeout('timed out')])
|
|
102
|
+
assert await mgr._post_charge_with_retries(event_name='page-open', count=1, idempotency_key='k1') is None
|
|
103
|
+
assert sum(s for s in no_sleep if s >= 0.5) <= ChargingManager._RETRY_BUDGET_SECS + 1
|
|
104
|
+
assert len({key for _, key in calls}) == 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_void_5xx_is_retried() -> None:
|
|
108
|
+
mgr, calls = manager_for([httpx.Response(503, json={'error': {'type': 'service-unavailable'}}), httpx.Response(200, json={})])
|
|
109
|
+
assert await mgr._void_charge('page-open', 'op-1', 1) is True
|
|
110
|
+
assert [path for path, _ in calls] == ['/v2/actor-runs/run-1/charge/void'] * 2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_flush_deadline_bounds_the_client_retries(no_sleep: List[float]) -> None:
|
|
114
|
+
"""A flush with a time budget stops retrying in time; the intent stays pending."""
|
|
115
|
+
import time as _time
|
|
116
|
+
|
|
117
|
+
mgr, calls = manager_for([httpx.Response(503, json={'error': {'type': 'service-unavailable'}})])
|
|
118
|
+
mgr._pending_charge_keys['k1'] = {'event_name': 'page-open', 'count': 1, 'created_at': _time.monotonic()}
|
|
119
|
+
clock = [1000.0]
|
|
120
|
+
|
|
121
|
+
def fake_monotonic() -> float:
|
|
122
|
+
return clock[0]
|
|
123
|
+
|
|
124
|
+
async def advancing_sleep(secs: float) -> None:
|
|
125
|
+
no_sleep.append(secs)
|
|
126
|
+
clock[0] += secs
|
|
127
|
+
|
|
128
|
+
import pytest as _pytest
|
|
129
|
+
mp = _pytest.MonkeyPatch()
|
|
130
|
+
try:
|
|
131
|
+
mp.setattr(client_mod.time, 'monotonic', fake_monotonic)
|
|
132
|
+
mp.setattr(charging_mod.time, 'monotonic', fake_monotonic)
|
|
133
|
+
mp.setattr(client_mod.asyncio, 'sleep', advancing_sleep)
|
|
134
|
+
mp.setattr(charging_mod.asyncio, 'sleep', advancing_sleep)
|
|
135
|
+
confirmed = await mgr.flush_pending(deadline_ms=3000)
|
|
136
|
+
finally:
|
|
137
|
+
mp.undo()
|
|
138
|
+
assert confirmed == 0
|
|
139
|
+
assert 'k1' in mgr._pending_charge_keys, 'still pending for a later flush'
|
|
140
|
+
assert sum(no_sleep) <= 3.0, f'no retry sleeps past the deadline: {no_sleep}'
|
|
141
|
+
assert len(calls) >= 2, 'it did retry within the budget'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Proxy input objects carry both namings; Actor.config exposes standby_port.
|
|
2
|
+
|
|
3
|
+
Actors read the proxy choice either as useScrapelyProxy / scrapelyProxyGroups /
|
|
4
|
+
scrapelyProxyCountry / scrapelyProxySubdivision or under the Apify names.
|
|
5
|
+
Actor.get_input() and Actor.input fill in whichever naming is missing, so both
|
|
6
|
+
reads see the user's choice whatever naming the input was written with.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from scrapely import Actor
|
|
18
|
+
from scrapely.configuration import Configuration
|
|
19
|
+
from scrapely.proxy_configuration import with_proxy_key_aliases
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
BOTH_RESIDENTIAL_CN = {
|
|
23
|
+
'useScrapelyProxy': True, 'useApifyProxy': True,
|
|
24
|
+
'scrapelyProxyGroups': ['RESIDENTIAL'], 'apifyProxyGroups': ['RESIDENTIAL'],
|
|
25
|
+
'scrapelyProxyCountry': 'CN', 'apifyProxyCountry': 'CN',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_scrapely_names_get_apify_aliases() -> None:
|
|
30
|
+
sent = {'proxy': {'useScrapelyProxy': True, 'scrapelyProxyGroups': ['RESIDENTIAL'], 'scrapelyProxyCountry': 'CN'}, 'q': 'x'}
|
|
31
|
+
out = with_proxy_key_aliases(sent)
|
|
32
|
+
assert out == {'proxy': BOTH_RESIDENTIAL_CN, 'q': 'x'}
|
|
33
|
+
assert sent['proxy'] == {'useScrapelyProxy': True, 'scrapelyProxyGroups': ['RESIDENTIAL'], 'scrapelyProxyCountry': 'CN'}, 'not mutated'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_apify_names_get_scrapely_aliases() -> None:
|
|
37
|
+
out = with_proxy_key_aliases({'proxy': {'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL'], 'apifyProxyCountry': 'CN'}})
|
|
38
|
+
assert out == {'proxy': BOTH_RESIDENTIAL_CN}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_proxy_off_and_custom_urls() -> None:
|
|
42
|
+
assert with_proxy_key_aliases({'p': {'useScrapelyProxy': False}}) == {'p': {'useScrapelyProxy': False, 'useApifyProxy': False}}
|
|
43
|
+
out = with_proxy_key_aliases({'p': {'useApifyProxy': False, 'proxyUrls': ['http://u:p@1.2.3.4:8080']}})
|
|
44
|
+
assert out == {'p': {'useScrapelyProxy': False, 'useApifyProxy': False, 'proxyUrls': ['http://u:p@1.2.3.4:8080']}}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_scrapely_value_wins_and_unset_values_stay() -> None:
|
|
48
|
+
out = with_proxy_key_aliases({'p': {
|
|
49
|
+
'useScrapelyProxy': False, 'useApifyProxy': True,
|
|
50
|
+
'scrapelyProxyGroups': None, 'apifyProxyGroups': ['DATACENTER'],
|
|
51
|
+
'scrapelyProxyCountry': 'DE', 'apifyProxyCountry': 'US', 'apifyProxySubdivision': None,
|
|
52
|
+
}})
|
|
53
|
+
assert out == {'p': {
|
|
54
|
+
'useScrapelyProxy': False, 'useApifyProxy': False,
|
|
55
|
+
'scrapelyProxyGroups': ['DATACENTER'], 'apifyProxyGroups': ['DATACENTER'],
|
|
56
|
+
'scrapelyProxyCountry': 'DE', 'apifyProxyCountry': 'DE', 'apifyProxySubdivision': None,
|
|
57
|
+
}}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_unrelated_fields_are_never_removed() -> None:
|
|
61
|
+
sent = {'settings': {'useApifyProxy': 'false', 'label': 'x'}}
|
|
62
|
+
assert with_proxy_key_aliases(sent) is sent, 'a non-boolean switch is kept as sent'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize('value', [None, 'x', 1, [], [{'useScrapelyProxy': True}], {'a': 1}, {'p': {'x': 1}}])
|
|
66
|
+
def test_other_values_unchanged(value: Any) -> None:
|
|
67
|
+
assert with_proxy_key_aliases(value) is value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_complete_input_is_returned_as_is() -> None:
|
|
71
|
+
sent = {'p': dict(BOTH_RESIDENTIAL_CN)}
|
|
72
|
+
assert with_proxy_key_aliases(sent) is sent
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_get_input_and_input_getter_alias(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
76
|
+
raw = {'proxyConfiguration': {'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL'], 'apifyProxyCountry': 'CN'}}
|
|
77
|
+
monkeypatch.setenv('ACTOR_INPUT', base64.b64encode(json.dumps(raw).encode()).decode())
|
|
78
|
+
monkeypatch.delenv('ACTOR_METAMORPH_COUNT', raising=False)
|
|
79
|
+
monkeypatch.setattr(Actor, '_cached_input', None)
|
|
80
|
+
monkeypatch.setattr(Actor, '_initialized', True, raising=False)
|
|
81
|
+
monkeypatch.setattr(Actor, '_ensure_initialized', classmethod(lambda cls, name: None))
|
|
82
|
+
got = await Actor.get_input()
|
|
83
|
+
assert got['proxyConfiguration'] == BOTH_RESIDENTIAL_CN
|
|
84
|
+
monkeypatch.setattr(Actor, '_cached_input', None)
|
|
85
|
+
assert Actor.input['proxyConfiguration'] == BOTH_RESIDENTIAL_CN
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_config_standby_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
89
|
+
monkeypatch.delenv('ACTOR_STANDBY_PORT', raising=False)
|
|
90
|
+
assert Configuration().standby_port == 4321
|
|
91
|
+
monkeypatch.setenv('ACTOR_STANDBY_PORT', '5000')
|
|
92
|
+
monkeypatch.setenv('ACTOR_META_ORIGIN', 'STANDBY')
|
|
93
|
+
config = Configuration()
|
|
94
|
+
assert config.standby_port == 5000
|
|
95
|
+
assert config.meta_origin == 'STANDBY', 'known settings are attributes too'
|
|
96
|
+
with pytest.raises(AttributeError):
|
|
97
|
+
config.not_a_setting # noqa: B018
|
|
98
|
+
assert Actor.config is Actor.configuration
|
|
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.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/_file_system/_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.6 → scrapely_python-0.1.7}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.6 → scrapely_python-0.1.7}/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.6 → scrapely_python-0.1.7}/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
|
|
File without changes
|
|
File without changes
|