scrapely-python 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scrapely/__init__.py CHANGED
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.2'
135
+ __version__ = '0.1.4'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
scrapely/actor.py CHANGED
@@ -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
- # {apifyProxyGroups: [...]} must not silently disable proxying.
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.get('apifyProxyCountry')
1803
- subdivision_code = subdivision_code or actor_proxy_input.get('apifyProxySubdivision')
1804
- groups = groups or actor_proxy_input.get('apifyProxyGroups')
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')
scrapely/charging.py CHANGED
@@ -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
- r = await charging_manager._charge_exclusive(default_event, len(limited_items))
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
- r = await charging_manager._charge_exclusive(event_name, len(limited_items))
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.get('apifyProxyGroups', []) or []
231
- self._country_code = actor_proxy_input.get('apifyProxyCountry')
232
- self._subdivision_code = actor_proxy_input.get('apifyProxySubdivision')
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT
@@ -1,15 +1,15 @@
1
- scrapely/__init__.py,sha256=qBRp9YUgItQprvVgBepI-5VWc6OuQ2LQjCdSp5Xviv0,5745
1
+ scrapely/__init__.py,sha256=nVwtGU9e9kfzoiX6MdfbK4U-p88J7eMDUkqNkpKY1Ug,5745
2
2
  scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
3
- scrapely/actor.py,sha256=wNifzbcvfH3wt0-aSaesJN_uZXNREhNfTgiORDGz7IM,75081
3
+ scrapely/actor.py,sha256=QczHOj2GVQnMbQZ_76vjjJNHxPrinWB_WCdZ6_41l00,75126
4
4
  scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
5
- scrapely/charging.py,sha256=ckqsGbGuzpdw5PgEt6Apx3cwR46sA8V2WwUDSqSSfz8,46887
5
+ scrapely/charging.py,sha256=MFz_Z4qJ-N3qLffleQb2BxjomygM3UMvHSBw1HabG80,56857
6
6
  scrapely/charging_client.py,sha256=tDtlPwLSVFvoRa2NBgNjyfHkAUM77SSIyYT2ACgpDm8,3710
7
7
  scrapely/configuration.py,sha256=YUnbft9JeWlVTgWalr-CgJCiyJ_e_rgoQItVPvjeWRs,15406
8
8
  scrapely/errors.py,sha256=DCAlVvE3jToZuPEG2HVDL7i8D8hLboomQ5JpSOcsXoQ,4107
9
9
  scrapely/log.py,sha256=iVKoQdMLQXEWz1STzbSXrNUxsS_FNwiIdqoEg7CzeqU,1440
10
10
  scrapely/patch_browser_pool.py,sha256=sRAjuzKoOEgn0X8695c_9qioRALzkTWXoDlorVnK9jg,37426
11
11
  scrapely/pricing_types.py,sha256=J6wCddZd-U1MYeOtaaEdeHSsp1HBgiQLfvSInBOvhIo,5305
12
- scrapely/proxy_configuration.py,sha256=i6icNhei8deVK9KgCC_hdeTuSawh8WiMUOrgjvkps1s,35845
12
+ scrapely/proxy_configuration.py,sha256=rSCXJFuUEIK31chk05FVLRQKeVA34h_pBRbr8IKNXo0,36195
13
13
  scrapely/py.typed,sha256=XtWlywqUjc6x_M2gAXlDtYXbAsYkCjKZEjFyJvaHvm4,105
14
14
  scrapely/types.py,sha256=ceVuHvhYbPai2CL3vFmXLTMoGaMR27IefJFgWhzd7ws,8900
15
15
  scrapely/webhook.py,sha256=4WOM42ypOSOs4lKtP5ofxAh6lX-7c8p5U9lOfuFzx-g,2830
@@ -37,7 +37,7 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=4Ipgbh4qEqAkwQJ9
37
37
  scrapely/storage/_file_system/_storage_client.py,sha256=aaGJ4P0QAtIgEMl6yHpuobeWFD5M4rMC5HwhOOvuGuo,1794
38
38
  scrapely/storage_clients/__init__.py,sha256=cs1YX7JV2_GKBXeiiJgkQFali08b5_y_jH6Yw7iROAA,656
39
39
  scrapely/storages/__init__.py,sha256=4PAELhJNZ3exJ7Uouicn-bCQ58NafKjhLBixQ8ATQc4,173
40
- scrapely_python-0.1.2.dist-info/METADATA,sha256=j0LJpAvLe7XGLBaGlKJkCq564TBZewFlGUDPgCbTB5U,3104
41
- scrapely_python-0.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
42
- scrapely_python-0.1.2.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
43
- scrapely_python-0.1.2.dist-info/RECORD,,
40
+ scrapely_python-0.1.4.dist-info/METADATA,sha256=ACM2A5RD1zJ8JV3cEY0jZigW3dpvsjQyiwJBFvKlLv4,3104
41
+ scrapely_python-0.1.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
42
+ scrapely_python-0.1.4.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
43
+ scrapely_python-0.1.4.dist-info/RECORD,,