scrapely-python 0.1.2__tar.gz → 0.1.4__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.
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/PKG-INFO +1 -1
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/pyproject.toml +1 -1
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/actor.py +5 -5
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/charging.py +198 -7
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/proxy_configuration.py +9 -3
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_charging_pending.py +285 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_proxy_configuration.py +42 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/README.md +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/setup.cfg +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/api_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.2 → scrapely_python-0.1.4}/tests/test_storage.py +0 -0
|
@@ -1776,7 +1776,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1776
1776
|
"""
|
|
1777
1777
|
cls._ensure_initialized('create_proxy_configuration')
|
|
1778
1778
|
|
|
1779
|
-
from .proxy_configuration import ProxyConfiguration, _input_use_platform_proxy
|
|
1779
|
+
from .proxy_configuration import ProxyConfiguration, _input_proxy_option, _input_use_platform_proxy
|
|
1780
1780
|
|
|
1781
1781
|
# Handle actor_proxy_input (Node SDK normalizeProxyInput/createProxyConfiguration
|
|
1782
1782
|
# parity): `useScrapelyProxy: false` (or the legacy `useApifyProxy: false`; a
|
|
@@ -1787,7 +1787,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1787
1787
|
# silently drop the user's own proxies and crawl on their real IPs. We only
|
|
1788
1788
|
# bail when no custom proxies are given either. A MISSING key falls back to
|
|
1789
1789
|
# platform mode when the server injected credentials — input like
|
|
1790
|
-
# {
|
|
1790
|
+
# {scrapelyProxyGroups: [...]} must not silently disable proxying.
|
|
1791
1791
|
if actor_proxy_input is not None:
|
|
1792
1792
|
if _input_use_platform_proxy(actor_proxy_input) is False and not (
|
|
1793
1793
|
proxy_urls
|
|
@@ -1799,9 +1799,9 @@ class Actor(metaclass=_ActorMeta):
|
|
|
1799
1799
|
or actor_proxy_input.get('tieredProxyConfig')
|
|
1800
1800
|
):
|
|
1801
1801
|
return None
|
|
1802
|
-
country_code = country_code or actor_proxy_input
|
|
1803
|
-
subdivision_code = subdivision_code or actor_proxy_input
|
|
1804
|
-
groups = groups or actor_proxy_input
|
|
1802
|
+
country_code = country_code or _input_proxy_option(actor_proxy_input, 'Country')
|
|
1803
|
+
subdivision_code = subdivision_code or _input_proxy_option(actor_proxy_input, 'Subdivision')
|
|
1804
|
+
groups = groups or _input_proxy_option(actor_proxy_input, 'Groups')
|
|
1805
1805
|
proxy_urls = proxy_urls or actor_proxy_input.get('proxyUrls')
|
|
1806
1806
|
tiered_proxy_urls = tiered_proxy_urls or actor_proxy_input.get('tieredProxyUrls')
|
|
1807
1807
|
tiered_proxy_config = tiered_proxy_config or actor_proxy_input.get('tieredProxyConfig')
|
|
@@ -195,6 +195,11 @@ class ChargingManager:
|
|
|
195
195
|
self._pending_charge_keys: Dict[str, Dict[str, Any]] = {}
|
|
196
196
|
# Optional local charging-log dataset (ACTOR_USE_CHARGING_LOG_DATASET).
|
|
197
197
|
self._charging_log_dataset: Any = None
|
|
198
|
+
# Idempotency key of the last charge the platform accepted
|
|
199
|
+
# (push_data_and_charge voids it when the push then fails).
|
|
200
|
+
self._last_accepted_operation_id: Optional[str] = None
|
|
201
|
+
# Voids not yet confirmed by the platform; retried before the next push and at exit.
|
|
202
|
+
self._pending_voids: List[tuple[str, str, int, bool]] = []
|
|
198
203
|
|
|
199
204
|
def close(self) -> None:
|
|
200
205
|
"""Mark the manager closed; subsequent charge/push operations are rejected."""
|
|
@@ -429,6 +434,67 @@ class ChargingManager:
|
|
|
429
434
|
self._total_charged_usd - event_price * Decimal(count),
|
|
430
435
|
)
|
|
431
436
|
|
|
437
|
+
def _take_last_accepted_operation_id(self) -> Optional[str]:
|
|
438
|
+
op_id = self._last_accepted_operation_id
|
|
439
|
+
self._last_accepted_operation_id = None
|
|
440
|
+
return op_id
|
|
441
|
+
|
|
442
|
+
async def _cancel_uncertain_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
|
|
443
|
+
"""Cancel an unconfirmed charge for items that were then not stored.
|
|
444
|
+
|
|
445
|
+
Drops the pending intent (so the exit flush never posts it) and voids it in
|
|
446
|
+
case the original request did apply; a 404 means it never applied.
|
|
447
|
+
"""
|
|
448
|
+
self._drop_pending(operation_id)
|
|
449
|
+
return await self._void_charge(event_name, operation_id, reserved, not_found_means_voided=True)
|
|
450
|
+
|
|
451
|
+
async def _void_charge(
|
|
452
|
+
self, event_name: str, operation_id: str, reserved: int, not_found_means_voided: bool = False
|
|
453
|
+
) -> bool:
|
|
454
|
+
"""Reverse an accepted charge whose items were never stored, then unwind locally.
|
|
455
|
+
|
|
456
|
+
POST /v2/actor-runs/{run}/charge/void with the charge's Idempotency-Key.
|
|
457
|
+
The local reservation is released only once the platform confirms.
|
|
458
|
+
"""
|
|
459
|
+
if self._client is None or not self._is_at_home or not self._actor_run_id:
|
|
460
|
+
return False
|
|
461
|
+
for attempt in range(self._POST_ATTEMPTS):
|
|
462
|
+
try:
|
|
463
|
+
await self._client.post(
|
|
464
|
+
f'/v2/actor-runs/{self._actor_run_id}/charge/void',
|
|
465
|
+
json_data={},
|
|
466
|
+
headers={'Idempotency-Key': operation_id},
|
|
467
|
+
)
|
|
468
|
+
self._unwind_reservation(event_name, reserved)
|
|
469
|
+
return True
|
|
470
|
+
except Exception as err: # noqa: BLE001
|
|
471
|
+
status = _http_status_of(err)
|
|
472
|
+
# Only the void route's own answer proves it never applied; a server
|
|
473
|
+
# without the route (older deployment) also answers 404.
|
|
474
|
+
if status == 404 and not_found_means_voided and getattr(err, 'type', None) == 'charge-not-found':
|
|
475
|
+
# The unconfirmed charge never reached the ledger: nothing billed.
|
|
476
|
+
self._unwind_reservation(event_name, reserved)
|
|
477
|
+
return True
|
|
478
|
+
# 404: nothing to void; 409: already settled; 4xx auth: permanent.
|
|
479
|
+
if status is not None and 400 <= status < 500:
|
|
480
|
+
return False
|
|
481
|
+
if attempt + 1 < self._POST_ATTEMPTS:
|
|
482
|
+
await asyncio.sleep(0.25 * (attempt + 1))
|
|
483
|
+
# Outcome unknown: keep the reservation and retry before the next push / at exit.
|
|
484
|
+
self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
|
|
485
|
+
logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
async def _retry_pending_voids(self) -> bool:
|
|
489
|
+
"""Retry unconfirmed voids (caller holds the operation lock).
|
|
490
|
+
|
|
491
|
+
Returns False while any earlier void is still unconfirmed.
|
|
492
|
+
"""
|
|
493
|
+
pending, self._pending_voids = self._pending_voids, []
|
|
494
|
+
for event_name, operation_id, reserved, not_found_means_voided in pending:
|
|
495
|
+
await self._void_charge(event_name, operation_id, reserved, not_found_means_voided)
|
|
496
|
+
return not self._pending_voids
|
|
497
|
+
|
|
432
498
|
def _quarantine_stale_pending(self) -> None:
|
|
433
499
|
"""Remove intents older than the safe replay window (fail closed, no replay).
|
|
434
500
|
|
|
@@ -544,6 +610,7 @@ class ChargingManager:
|
|
|
544
610
|
"""Caller must hold _operation_lock."""
|
|
545
611
|
if not self._is_at_home or self._client is None or not self._actor_run_id:
|
|
546
612
|
return 0
|
|
613
|
+
await self._retry_pending_voids()
|
|
547
614
|
|
|
548
615
|
import time
|
|
549
616
|
|
|
@@ -731,6 +798,8 @@ class ChargingManager:
|
|
|
731
798
|
)
|
|
732
799
|
if accepted is not None:
|
|
733
800
|
self._drop_pending(idempotency_key)
|
|
801
|
+
if accepted > 0:
|
|
802
|
+
self._last_accepted_operation_id = idempotency_key
|
|
734
803
|
if accepted < chargeable_count:
|
|
735
804
|
self._unwind_reservation(event_name, chargeable_count - accepted)
|
|
736
805
|
chargeable_count = accepted
|
|
@@ -1032,6 +1101,9 @@ async def push_data_and_charge(
|
|
|
1032
1101
|
async with charging_manager._operation_lock:
|
|
1033
1102
|
if charging_manager._closed:
|
|
1034
1103
|
raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
|
|
1104
|
+
# A void that did not go through for an earlier failed push must land
|
|
1105
|
+
# before this push charges again, or one unsaved result is billed twice.
|
|
1106
|
+
voids_confirmed = await charging_manager._retry_pending_voids()
|
|
1035
1107
|
# Non-PPE actors push everything without charging (JS calculatePushDataLimits
|
|
1036
1108
|
# parity: `if (!this.isPayPerEvent) return { limitedItems, eventsToCharge: {} }`).
|
|
1037
1109
|
is_ppe = charging_manager._pricing_info is not None
|
|
@@ -1065,6 +1137,29 @@ async def push_data_and_charge(
|
|
|
1065
1137
|
event_charge_limit_reached=len(items) > 0 and limited_count == 0,
|
|
1066
1138
|
)
|
|
1067
1139
|
|
|
1140
|
+
# Every platform event this push would charge counts: the explicit event
|
|
1141
|
+
# and, on a default-dataset push, the configured default event. The
|
|
1142
|
+
# synthetic default-dataset event (local bookkeeping) and unpriced
|
|
1143
|
+
# events (never posted) do not.
|
|
1144
|
+
charged_names: List[str] = []
|
|
1145
|
+
if event_name:
|
|
1146
|
+
charged_names.append(event_name)
|
|
1147
|
+
if is_default_dataset:
|
|
1148
|
+
configured_default = charging_manager._config.get('default_dataset_item_event_name')
|
|
1149
|
+
if configured_default:
|
|
1150
|
+
charged_names.append(configured_default)
|
|
1151
|
+
if (
|
|
1152
|
+
not voids_confirmed
|
|
1153
|
+
and is_ppe
|
|
1154
|
+
and any(
|
|
1155
|
+
not is_default_dataset_item_event(name) and name in charging_manager._per_event_prices
|
|
1156
|
+
for name in charged_names
|
|
1157
|
+
)
|
|
1158
|
+
):
|
|
1159
|
+
raise RuntimeError(
|
|
1160
|
+
'An earlier charge for a failed dataset push could not be voided yet; retry the push'
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1068
1163
|
# Charge BEFORE persisting so a server cap/reject cannot leave unbilled
|
|
1069
1164
|
# items in the dataset. Operation lock is held across both steps.
|
|
1070
1165
|
if is_ppe and resolved_event:
|
|
@@ -1079,15 +1174,40 @@ async def push_data_and_charge(
|
|
|
1079
1174
|
|
|
1080
1175
|
# Synthetic storage event first (local-only); then explicit billable event.
|
|
1081
1176
|
reserved_in_op: list[tuple[str, int]] = []
|
|
1177
|
+
operation_ids: list[Optional[str]] = []
|
|
1178
|
+
uncertain_ops: set[str] = set()
|
|
1179
|
+
|
|
1180
|
+
async def charge_one(name: str) -> None:
|
|
1181
|
+
charging_manager._take_last_accepted_operation_id()
|
|
1182
|
+
try:
|
|
1183
|
+
r = await charging_manager._charge_exclusive(name, len(limited_items))
|
|
1184
|
+
op_id = charging_manager._take_last_accepted_operation_id()
|
|
1185
|
+
except ChargeUncertainError as unconfirmed:
|
|
1186
|
+
# The platform did not confirm the charge (5xx / timeouts after
|
|
1187
|
+
# retries). Never drop the items for that: the intent stays
|
|
1188
|
+
# pending with its idempotency key and is re-posted by the exit
|
|
1189
|
+
# flush, so the items are stored now and billed once.
|
|
1190
|
+
logger.warning(
|
|
1191
|
+
'Charge for %r not confirmed yet (%s); storing the items and retrying the charge later',
|
|
1192
|
+
name,
|
|
1193
|
+
unconfirmed.operation_id,
|
|
1194
|
+
)
|
|
1195
|
+
r = ChargeResult(
|
|
1196
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1197
|
+
charged_count=unconfirmed.count,
|
|
1198
|
+
event_charge_limit_reached=False,
|
|
1199
|
+
)
|
|
1200
|
+
op_id = unconfirmed.operation_id
|
|
1201
|
+
uncertain_ops.add(op_id)
|
|
1202
|
+
results.append(r)
|
|
1203
|
+
reserved_in_op.append((name, r.charged_count))
|
|
1204
|
+
operation_ids.append(op_id)
|
|
1205
|
+
|
|
1082
1206
|
try:
|
|
1083
1207
|
if default_event:
|
|
1084
|
-
|
|
1085
|
-
results.append(r)
|
|
1086
|
-
reserved_in_op.append((default_event, r.charged_count))
|
|
1208
|
+
await charge_one(default_event)
|
|
1087
1209
|
if event_name and event_name != default_event:
|
|
1088
|
-
|
|
1089
|
-
results.append(r)
|
|
1090
|
-
reserved_in_op.append((event_name, r.charged_count))
|
|
1210
|
+
await charge_one(event_name)
|
|
1091
1211
|
except Exception:
|
|
1092
1212
|
for name, reserved in reserved_in_op:
|
|
1093
1213
|
if reserved > 0:
|
|
@@ -1109,20 +1229,59 @@ async def push_data_and_charge(
|
|
|
1109
1229
|
)
|
|
1110
1230
|
# Unwind any event that reserved more than we will persist
|
|
1111
1231
|
# (synthetic default-dataset is local-only and otherwise sticks).
|
|
1232
|
+
platform_op_names = {
|
|
1233
|
+
name for (name, _), op_id in zip(reserved_in_op, operation_ids) if op_id
|
|
1234
|
+
}
|
|
1112
1235
|
for name, result in charged_events:
|
|
1113
1236
|
if result.charged_count > accepted_for_items:
|
|
1237
|
+
# Nothing stored and a platform charge was made: the void
|
|
1238
|
+
# below releases its local count once the platform confirms.
|
|
1239
|
+
if accepted_for_items <= 0 and name in platform_op_names:
|
|
1240
|
+
continue
|
|
1114
1241
|
charging_manager._unwind_reservation(
|
|
1115
1242
|
name, result.charged_count - accepted_for_items
|
|
1116
1243
|
)
|
|
1244
|
+
if accepted_for_items <= 0:
|
|
1245
|
+
# Nothing will be stored (another event of this push was capped
|
|
1246
|
+
# to zero): undo every platform charge this operation made, so
|
|
1247
|
+
# no result is billed for items that are never saved. Local
|
|
1248
|
+
# counts were unwound just above.
|
|
1249
|
+
for (name, reserved), op_id in zip(reserved_in_op, operation_ids):
|
|
1250
|
+
if reserved <= 0 or not op_id:
|
|
1251
|
+
continue
|
|
1252
|
+
# Original count: released only when the platform confirms.
|
|
1253
|
+
if op_id in uncertain_ops:
|
|
1254
|
+
await charging_manager._cancel_uncertain_charge(name, op_id, reserved)
|
|
1255
|
+
else:
|
|
1256
|
+
await charging_manager._void_charge(name, op_id, reserved)
|
|
1117
1257
|
if accepted_for_items > 0:
|
|
1118
1258
|
try:
|
|
1119
1259
|
await push_fn(limited_items[:accepted_for_items])
|
|
1120
1260
|
except Exception as push_err:
|
|
1261
|
+
# A result is billed only if it was saved: void this
|
|
1262
|
+
# operation's platform charges and unwind local
|
|
1263
|
+
# reservations, then let the caller retry the push.
|
|
1121
1264
|
logger.error(
|
|
1122
|
-
'Dataset push failed after charge accepted (items=%s): %s',
|
|
1265
|
+
'Dataset push failed after charge accepted (items=%s): %s; voiding the charge',
|
|
1123
1266
|
accepted_for_items,
|
|
1124
1267
|
push_err,
|
|
1125
1268
|
)
|
|
1269
|
+
# Only when the push certainly stored nothing (the storage API
|
|
1270
|
+
# answered with an error, or the request was never sent). A
|
|
1271
|
+
# timeout after sending may have stored the items.
|
|
1272
|
+
if not _push_certainly_failed(push_err):
|
|
1273
|
+
raise
|
|
1274
|
+
for (name, _), op_id in zip(reserved_in_op, operation_ids):
|
|
1275
|
+
kept = min(accepted_for_items, next(r.charged_count for n, r in charged_events if n == name))
|
|
1276
|
+
if kept <= 0:
|
|
1277
|
+
continue
|
|
1278
|
+
if op_id and op_id in uncertain_ops:
|
|
1279
|
+
# Never re-post a charge for items that were not stored.
|
|
1280
|
+
await charging_manager._cancel_uncertain_charge(name, op_id, kept)
|
|
1281
|
+
elif op_id:
|
|
1282
|
+
await charging_manager._void_charge(name, op_id, kept)
|
|
1283
|
+
elif is_default_dataset_item_event(name):
|
|
1284
|
+
charging_manager._unwind_reservation(name, kept)
|
|
1126
1285
|
raise
|
|
1127
1286
|
return ChargeResult(
|
|
1128
1287
|
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
@@ -1141,3 +1300,35 @@ async def push_data_and_charge(
|
|
|
1141
1300
|
charged_count=0,
|
|
1142
1301
|
event_charge_limit_reached=False,
|
|
1143
1302
|
)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _http_status_of(err: BaseException) -> Optional[int]:
|
|
1306
|
+
status = getattr(err, 'status_code', None)
|
|
1307
|
+
if status is None:
|
|
1308
|
+
response = getattr(err, 'response', None)
|
|
1309
|
+
status = getattr(response, 'status_code', None) if response is not None else None
|
|
1310
|
+
try:
|
|
1311
|
+
return int(status) if status is not None else None
|
|
1312
|
+
except (TypeError, ValueError):
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _push_certainly_failed(err: BaseException) -> bool:
|
|
1317
|
+
"""True when a dataset push certainly stored nothing.
|
|
1318
|
+
|
|
1319
|
+
The storage API answered with an error status, or the connection was never
|
|
1320
|
+
made. Timeouts and resets after sending are not proof: the write may have landed.
|
|
1321
|
+
"""
|
|
1322
|
+
import httpx
|
|
1323
|
+
|
|
1324
|
+
e: Optional[BaseException] = err
|
|
1325
|
+
for _ in range(4):
|
|
1326
|
+
if e is None:
|
|
1327
|
+
break
|
|
1328
|
+
status = _http_status_of(e)
|
|
1329
|
+
if status is not None:
|
|
1330
|
+
return status >= 400
|
|
1331
|
+
if isinstance(e, (httpx.ConnectError, ConnectionRefusedError)):
|
|
1332
|
+
return True
|
|
1333
|
+
e = e.__cause__ or e.__context__
|
|
1334
|
+
return False
|
|
@@ -121,6 +121,12 @@ def _input_use_platform_proxy(actor_proxy_input: Dict[str, Any]) -> Optional[boo
|
|
|
121
121
|
return None
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
def _input_proxy_option(actor_proxy_input: Dict[str, Any], name: str) -> Any:
|
|
125
|
+
"""`scrapelyProxy<name>` of the proxy input field when it is set, else the legacy `apifyProxy<name>`."""
|
|
126
|
+
value = actor_proxy_input.get(f'scrapelyProxy{name}')
|
|
127
|
+
return value if value is not None else actor_proxy_input.get(f'apifyProxy{name}')
|
|
128
|
+
|
|
129
|
+
|
|
124
130
|
def _platform_env() -> Optional[Dict[str, Any]]:
|
|
125
131
|
"""Read platform proxy credentials from the environment (or None)."""
|
|
126
132
|
hostname = os.environ.get('SCRAPELY_PROXY_HOSTNAME') or os.environ.get('APIFY_PROXY_HOSTNAME')
|
|
@@ -227,9 +233,9 @@ class ProxyConfiguration:
|
|
|
227
233
|
# If actor_proxy_input is provided, extract settings from it
|
|
228
234
|
if actor_proxy_input:
|
|
229
235
|
if _input_use_platform_proxy(actor_proxy_input):
|
|
230
|
-
self._groups = actor_proxy_input
|
|
231
|
-
self._country_code = actor_proxy_input
|
|
232
|
-
self._subdivision_code = actor_proxy_input
|
|
236
|
+
self._groups = _input_proxy_option(actor_proxy_input, 'Groups') or []
|
|
237
|
+
self._country_code = _input_proxy_option(actor_proxy_input, 'Country')
|
|
238
|
+
self._subdivision_code = _input_proxy_option(actor_proxy_input, 'Subdivision')
|
|
233
239
|
else:
|
|
234
240
|
self._proxy_urls = actor_proxy_input.get('proxyUrls', []) or []
|
|
235
241
|
# Tiered lists may also arrive through actor input (future-proof
|
|
@@ -457,3 +457,288 @@ async def test_dual_event_push_unwinds_synthetic_on_partial_explicit() -> None:
|
|
|
457
457
|
assert len(pushed) == 1
|
|
458
458
|
assert mgr.get_charged_event_count('page-open') == 1
|
|
459
459
|
assert mgr.get_charged_event_count('apify-default-dataset-item') == 1
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@pytest.mark.asyncio
|
|
463
|
+
async def test_push_failure_after_charge_voids_the_charge() -> None:
|
|
464
|
+
"""A result is billed only if it was saved: a failed push voids its charge."""
|
|
465
|
+
client = FakeClient()
|
|
466
|
+
mgr = _make_manager(client)
|
|
467
|
+
|
|
468
|
+
class PushHttpError(Exception):
|
|
469
|
+
status_code = 503
|
|
470
|
+
|
|
471
|
+
async def failing_push(_items: Any) -> None:
|
|
472
|
+
raise PushHttpError('503 from dataset push')
|
|
473
|
+
|
|
474
|
+
with pytest.raises(PushHttpError, match='503'):
|
|
475
|
+
await push_data_and_charge(mgr, [{'a': 1}, {'a': 2}], 'page-open', False, failing_push)
|
|
476
|
+
|
|
477
|
+
charge_posts = [p for p in client.posts if p['path'].endswith('/charge')]
|
|
478
|
+
void_posts = [p for p in client.posts if p['path'].endswith('/charge/void')]
|
|
479
|
+
assert len(charge_posts) == 1
|
|
480
|
+
assert len(void_posts) == 1
|
|
481
|
+
assert void_posts[0]['path'] == '/v2/actor-runs/run-test-1/charge/void'
|
|
482
|
+
assert void_posts[0]['headers']['Idempotency-Key'] == charge_posts[0]['headers']['Idempotency-Key']
|
|
483
|
+
assert mgr.get_charged_event_count('page-open') == 0
|
|
484
|
+
|
|
485
|
+
pushed: List[Any] = []
|
|
486
|
+
|
|
487
|
+
async def ok_push(items: Any) -> None:
|
|
488
|
+
pushed.extend(items)
|
|
489
|
+
|
|
490
|
+
await push_data_and_charge(mgr, [{'a': 3}], 'page-open', False, ok_push)
|
|
491
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge/void')]) == 1
|
|
492
|
+
assert mgr.get_charged_event_count('page-open') == 1
|
|
493
|
+
assert pushed == [{'a': 3}]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class VoidFlakyClient(FakeClient):
|
|
498
|
+
def __init__(self, void_failures: int = 0) -> None:
|
|
499
|
+
super().__init__()
|
|
500
|
+
self.void_failures = void_failures
|
|
501
|
+
|
|
502
|
+
async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
503
|
+
if path.endswith('/charge/void') and self.void_failures > 0:
|
|
504
|
+
self.void_failures -= 1
|
|
505
|
+
self.posts.append({'path': path + '#failed', 'json': json_data, 'headers': headers or {}})
|
|
506
|
+
raise TimeoutError('void timed out')
|
|
507
|
+
return await super().post(path, json_data=json_data, headers=headers, **kwargs)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@pytest.mark.asyncio
|
|
511
|
+
async def test_push_timeout_after_send_keeps_the_charge() -> None:
|
|
512
|
+
client = FakeClient()
|
|
513
|
+
mgr = _make_manager(client)
|
|
514
|
+
|
|
515
|
+
async def reset_push(_items: Any) -> None:
|
|
516
|
+
raise TimeoutError('read timed out') # may have been stored
|
|
517
|
+
|
|
518
|
+
with pytest.raises(TimeoutError):
|
|
519
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, reset_push)
|
|
520
|
+
assert not [p for p in client.posts if p['path'].endswith('/charge/void')]
|
|
521
|
+
assert mgr.get_charged_event_count('page-open') == 1
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@pytest.mark.asyncio
|
|
525
|
+
async def test_unconfirmed_void_is_retried_before_next_charge() -> None:
|
|
526
|
+
client = VoidFlakyClient(void_failures=1)
|
|
527
|
+
mgr = _make_manager(client)
|
|
528
|
+
|
|
529
|
+
class PushHttpError(Exception):
|
|
530
|
+
status_code = 502
|
|
531
|
+
|
|
532
|
+
async def failing_push(_items: Any) -> None:
|
|
533
|
+
raise PushHttpError('bad gateway')
|
|
534
|
+
|
|
535
|
+
with pytest.raises(PushHttpError):
|
|
536
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
537
|
+
assert mgr.get_charged_event_count('page-open') == 1 # void unconfirmed: reservation kept
|
|
538
|
+
|
|
539
|
+
async def ok_push(_items: Any) -> None:
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, ok_push)
|
|
543
|
+
paths = [p['path'].rsplit('/', 1)[-1] for p in client.posts]
|
|
544
|
+
assert paths == ['charge', 'void#failed', 'void', 'charge']
|
|
545
|
+
assert mgr.get_charged_event_count('page-open') == 1
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@pytest.mark.asyncio
|
|
550
|
+
async def test_new_push_refused_while_earlier_void_unconfirmed() -> None:
|
|
551
|
+
client = VoidFlakyClient(void_failures=99)
|
|
552
|
+
mgr = _make_manager(client)
|
|
553
|
+
|
|
554
|
+
class PushHttpError(Exception):
|
|
555
|
+
status_code = 502
|
|
556
|
+
|
|
557
|
+
async def failing_push(_items: Any) -> None:
|
|
558
|
+
raise PushHttpError('bad gateway')
|
|
559
|
+
|
|
560
|
+
with pytest.raises(PushHttpError):
|
|
561
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
562
|
+
|
|
563
|
+
async def ok_push(_items: Any) -> None:
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
with pytest.raises(RuntimeError, match='could not be voided yet'):
|
|
567
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, ok_push)
|
|
568
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@pytest.mark.asyncio
|
|
572
|
+
async def test_uncharged_push_not_blocked_by_unconfirmed_void() -> None:
|
|
573
|
+
client = VoidFlakyClient(void_failures=99)
|
|
574
|
+
mgr = _make_manager(client)
|
|
575
|
+
|
|
576
|
+
class PushHttpError(Exception):
|
|
577
|
+
status_code = 502
|
|
578
|
+
|
|
579
|
+
async def failing_push(_items: Any) -> None:
|
|
580
|
+
raise PushHttpError('bad gateway')
|
|
581
|
+
|
|
582
|
+
with pytest.raises(PushHttpError):
|
|
583
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
584
|
+
|
|
585
|
+
pushed: List[Any] = []
|
|
586
|
+
|
|
587
|
+
async def ok_push(items: Any) -> None:
|
|
588
|
+
pushed.append(items)
|
|
589
|
+
|
|
590
|
+
await push_data_and_charge(mgr, [{'b': 1}], None, False, ok_push)
|
|
591
|
+
assert len(pushed) == 1
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@pytest.mark.asyncio
|
|
595
|
+
async def test_configured_platform_default_event_is_gated_by_unconfirmed_void() -> None:
|
|
596
|
+
client = VoidFlakyClient(void_failures=99)
|
|
597
|
+
mgr = _make_manager(client)
|
|
598
|
+
|
|
599
|
+
class PushHttpError(Exception):
|
|
600
|
+
status_code = 502
|
|
601
|
+
|
|
602
|
+
async def failing_push(_items: Any) -> None:
|
|
603
|
+
raise PushHttpError('bad gateway')
|
|
604
|
+
|
|
605
|
+
with pytest.raises(PushHttpError):
|
|
606
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
607
|
+
|
|
608
|
+
# A configured default event that is a real platform event must be gated too.
|
|
609
|
+
mgr._config.get = MagicMock(side_effect=lambda key, default=None: 'page-open' if key == 'default_dataset_item_event_name' else None)
|
|
610
|
+
|
|
611
|
+
async def ok_push(_items: Any) -> None:
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
with pytest.raises(RuntimeError, match='could not be voided yet'):
|
|
615
|
+
await push_data_and_charge(mgr, [{'b': 1}], None, True, ok_push)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@pytest.mark.asyncio
|
|
619
|
+
async def test_unpriced_configured_default_event_not_blocked() -> None:
|
|
620
|
+
client = VoidFlakyClient(void_failures=99)
|
|
621
|
+
mgr = _make_manager(client)
|
|
622
|
+
|
|
623
|
+
class PushHttpError(Exception):
|
|
624
|
+
status_code = 502
|
|
625
|
+
|
|
626
|
+
async def failing_push(_items: Any) -> None:
|
|
627
|
+
raise PushHttpError('bad gateway')
|
|
628
|
+
|
|
629
|
+
with pytest.raises(PushHttpError):
|
|
630
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
631
|
+
mgr._config.get = MagicMock(side_effect=lambda key, default=None: 'not-priced' if key == 'default_dataset_item_event_name' else None)
|
|
632
|
+
pushed: List[Any] = []
|
|
633
|
+
|
|
634
|
+
async def ok_push(items: Any) -> None:
|
|
635
|
+
pushed.append(items)
|
|
636
|
+
|
|
637
|
+
await push_data_and_charge(mgr, [{'b': 1}], None, True, ok_push)
|
|
638
|
+
assert len(pushed) == 1
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@pytest.mark.asyncio
|
|
642
|
+
async def test_unpriced_explicit_event_with_priced_default_is_gated() -> None:
|
|
643
|
+
client = VoidFlakyClient(void_failures=99)
|
|
644
|
+
mgr = _make_manager(client)
|
|
645
|
+
|
|
646
|
+
class PushHttpError(Exception):
|
|
647
|
+
status_code = 502
|
|
648
|
+
|
|
649
|
+
async def failing_push(_items: Any) -> None:
|
|
650
|
+
raise PushHttpError('bad gateway')
|
|
651
|
+
|
|
652
|
+
with pytest.raises(PushHttpError):
|
|
653
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
654
|
+
# Configured default event is priced; the explicit event is not.
|
|
655
|
+
mgr._config.get = MagicMock(side_effect=lambda key, default=None: 'page-open' if key == 'default_dataset_item_event_name' else None)
|
|
656
|
+
|
|
657
|
+
async def ok_push(_items: Any) -> None:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
with pytest.raises(RuntimeError, match='could not be voided yet'):
|
|
661
|
+
await push_data_and_charge(mgr, [{'b': 1}], 'not-priced', True, ok_push)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@pytest.mark.asyncio
|
|
665
|
+
async def test_charge_5xx_never_drops_items_and_flush_bills_once() -> None:
|
|
666
|
+
client = FakeClient(fail_times=1) # first charge POST fails (unconfirmed)
|
|
667
|
+
mgr = _make_manager(client)
|
|
668
|
+
pushed: List[Any] = []
|
|
669
|
+
|
|
670
|
+
async def ok_push(items: Any) -> None:
|
|
671
|
+
pushed.extend(items)
|
|
672
|
+
|
|
673
|
+
result = await push_data_and_charge(mgr, [{'a': 1}, {'a': 2}], 'page-open', False, ok_push)
|
|
674
|
+
assert pushed == [{'a': 1}, {'a': 2}], 'items must be stored despite the failed charge'
|
|
675
|
+
assert result.charged_count == 2
|
|
676
|
+
charge_keys = [p['headers']['Idempotency-Key'] for p in client.posts if p['path'].endswith('/charge')]
|
|
677
|
+
await mgr.flush_pending()
|
|
678
|
+
charge_keys_after = [p['headers']['Idempotency-Key'] for p in client.posts if p['path'].endswith('/charge')]
|
|
679
|
+
assert len(charge_keys) == 1 and charge_keys_after == charge_keys * 2, 'same key re-posted once by the flush'
|
|
680
|
+
assert mgr.get_charged_event_count('page-open') == 2
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class Void404Client(FakeClient):
|
|
684
|
+
async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
685
|
+
if path.endswith('/charge/void'):
|
|
686
|
+
self.posts.append({'path': path, 'json': json_data, 'headers': headers or {}})
|
|
687
|
+
|
|
688
|
+
class NotFound(Exception):
|
|
689
|
+
status_code = 404
|
|
690
|
+
type = 'charge-not-found'
|
|
691
|
+
|
|
692
|
+
raise NotFound('charge not found')
|
|
693
|
+
return await super().post(path, json_data=json_data, headers=headers, **kwargs)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@pytest.mark.asyncio
|
|
697
|
+
async def test_unconfirmed_charge_for_unsaved_items_is_cancelled() -> None:
|
|
698
|
+
client = Void404Client(fail_times=1)
|
|
699
|
+
mgr = _make_manager(client)
|
|
700
|
+
|
|
701
|
+
class PushHttpError(Exception):
|
|
702
|
+
status_code = 502
|
|
703
|
+
|
|
704
|
+
async def failing_push(_items: Any) -> None:
|
|
705
|
+
raise PushHttpError('bad gateway')
|
|
706
|
+
|
|
707
|
+
with pytest.raises(PushHttpError):
|
|
708
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
709
|
+
key = next(p['headers']['Idempotency-Key'] for p in client.posts if p['path'].endswith('/charge'))
|
|
710
|
+
assert any(p['path'].endswith('/charge/void') and p['headers']['Idempotency-Key'] == key for p in client.posts)
|
|
711
|
+
await mgr.flush_pending()
|
|
712
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1, 'never re-posted'
|
|
713
|
+
assert mgr.get_charged_event_count('page-open') == 0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@pytest.mark.asyncio
|
|
718
|
+
async def test_void_route_missing_404_is_not_never_applied() -> None:
|
|
719
|
+
class GenericNotFoundClient(FakeClient):
|
|
720
|
+
async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Dict[str, Any]:
|
|
721
|
+
if path.endswith('/charge/void'):
|
|
722
|
+
self.posts.append({'path': path, 'json': json_data, 'headers': headers or {}})
|
|
723
|
+
|
|
724
|
+
class RouteNotFound(Exception):
|
|
725
|
+
status_code = 404
|
|
726
|
+
type = 'not-found'
|
|
727
|
+
|
|
728
|
+
raise RouteNotFound('route not found')
|
|
729
|
+
return await super().post(path, json_data=json_data, headers=headers, **kwargs)
|
|
730
|
+
|
|
731
|
+
client = GenericNotFoundClient(fail_times=1)
|
|
732
|
+
mgr = _make_manager(client)
|
|
733
|
+
|
|
734
|
+
class PushHttpError(Exception):
|
|
735
|
+
status_code = 502
|
|
736
|
+
|
|
737
|
+
async def failing_push(_items: Any) -> None:
|
|
738
|
+
raise PushHttpError('bad gateway')
|
|
739
|
+
|
|
740
|
+
with pytest.raises(PushHttpError):
|
|
741
|
+
await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
|
|
742
|
+
await mgr.flush_pending()
|
|
743
|
+
assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1, 'never re-posted for unsaved items'
|
|
744
|
+
assert mgr.get_charged_event_count('page-open') == 1, 'local count kept: it may have applied'
|
|
@@ -663,6 +663,48 @@ def test_proxy_configuration_actor_input_use_scrapely_proxy() -> None:
|
|
|
663
663
|
assert custom._groups == []
|
|
664
664
|
|
|
665
665
|
|
|
666
|
+
def test_proxy_configuration_actor_input_scrapely_proxy_options() -> None:
|
|
667
|
+
pc = ProxyConfiguration(actor_proxy_input={
|
|
668
|
+
'useScrapelyProxy': True,
|
|
669
|
+
'scrapelyProxyGroups': ['RESIDENTIAL'],
|
|
670
|
+
'scrapelyProxyCountry': 'US',
|
|
671
|
+
'scrapelyProxySubdivision': 'CA',
|
|
672
|
+
})
|
|
673
|
+
assert pc._groups == ['RESIDENTIAL']
|
|
674
|
+
assert pc._country_code == 'US'
|
|
675
|
+
assert pc._subdivision_code == 'CA'
|
|
676
|
+
both = ProxyConfiguration(actor_proxy_input={
|
|
677
|
+
'useScrapelyProxy': True,
|
|
678
|
+
'scrapelyProxyGroups': ['RESIDENTIAL'],
|
|
679
|
+
'apifyProxyGroups': ['DATACENTER'],
|
|
680
|
+
'apifyProxyCountry': 'DE',
|
|
681
|
+
})
|
|
682
|
+
assert both._groups == ['RESIDENTIAL']
|
|
683
|
+
assert both._country_code == 'DE'
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
async def test_actor_create_scrapely_proxy_groups_uses_platform(initialized_actor) -> None:
|
|
687
|
+
pc = await initialized_actor.create_proxy_configuration(
|
|
688
|
+
actor_proxy_input={'useScrapelyProxy': True, 'scrapelyProxyGroups': ['RESIDENTIAL'], 'scrapelyProxyCountry': 'US'},
|
|
689
|
+
check_access=False,
|
|
690
|
+
)
|
|
691
|
+
assert pc is not None
|
|
692
|
+
url = await pc.new_url()
|
|
693
|
+
assert 'groups-RESIDENTIAL' in url
|
|
694
|
+
assert 'country-US' in url
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
async def test_actor_create_legacy_apify_proxy_groups_still_work(initialized_actor) -> None:
|
|
698
|
+
pc = await initialized_actor.create_proxy_configuration(
|
|
699
|
+
actor_proxy_input={'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL'], 'apifyProxyCountry': 'US'},
|
|
700
|
+
check_access=False,
|
|
701
|
+
)
|
|
702
|
+
assert pc is not None
|
|
703
|
+
url = await pc.new_url()
|
|
704
|
+
assert 'groups-RESIDENTIAL' in url
|
|
705
|
+
assert 'country-US' in url
|
|
706
|
+
|
|
707
|
+
|
|
666
708
|
async def test_actor_create_no_options_without_env_returns_none(
|
|
667
709
|
initialized_actor, monkeypatch: pytest.MonkeyPatch
|
|
668
710
|
) -> None:
|
|
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.2 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.2 → scrapely_python-0.1.4}/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.2 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.2 → scrapely_python-0.1.4}/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
|
|
File without changes
|
{scrapely_python-0.1.2 → scrapely_python-0.1.4}/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
|