scrapely-python 0.1.3__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.
Files changed (58) hide show
  1. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/charging.py +76 -16
  5. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  6. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_charging_pending.py +83 -0
  7. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/README.md +0 -0
  8. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/setup.cfg +0 -0
  9. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/_browser_locations.py +0 -0
  10. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/actor.py +0 -0
  11. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/browser_configuration.py +0 -0
  12. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/charging_client.py +0 -0
  13. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/configuration.py +0 -0
  14. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/crypto/__init__.py +0 -0
  15. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/crypto/_secrets.py +0 -0
  16. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/errors.py +0 -0
  17. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/events/__init__.py +0 -0
  18. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/events/_types.py +0 -0
  19. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/events/event_manager.py +0 -0
  20. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/log.py +0 -0
  21. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/patch_browser_pool.py +0 -0
  22. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/pricing_types.py +0 -0
  23. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/proxy_configuration.py +0 -0
  24. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/py.typed +0 -0
  25. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/request_loaders/__init__.py +0 -0
  26. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  27. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/__init__.py +0 -0
  28. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/__init__.py +0 -0
  29. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  30. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  31. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/_metadata.py +0 -0
  32. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/api_client.py +0 -0
  33. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/client.py +0 -0
  34. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/patch_storage.py +0 -0
  35. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  36. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  37. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  38. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  39. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  40. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/types.py +0 -0
  41. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage/utils.py +0 -0
  42. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storage_clients/__init__.py +0 -0
  43. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/storages/__init__.py +0 -0
  44. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/types.py +0 -0
  45. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely/webhook.py +0 -0
  46. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  47. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  48. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/requires.txt +0 -0
  49. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/src/scrapely_python.egg-info/top_level.txt +0 -0
  50. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_apify_parity.py +0 -0
  51. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_browser_configuration.py +0 -0
  52. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_charging_parity.py +0 -0
  53. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_parity.py +0 -0
  54. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_patch_browser_pool.py +0 -0
  55. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_proxy_configuration.py +0 -0
  56. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_run_control.py +0 -0
  57. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_secrets.py +0 -0
  58. {scrapely_python-0.1.3 → scrapely_python-0.1.4}/tests/test_storage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.3'
135
+ __version__ = '0.1.4'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -199,7 +199,7 @@ class ChargingManager:
199
199
  # (push_data_and_charge voids it when the push then fails).
200
200
  self._last_accepted_operation_id: Optional[str] = None
201
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]] = []
202
+ self._pending_voids: List[tuple[str, str, int, bool]] = []
203
203
 
204
204
  def close(self) -> None:
205
205
  """Mark the manager closed; subsequent charge/push operations are rejected."""
@@ -439,7 +439,18 @@ class ChargingManager:
439
439
  self._last_accepted_operation_id = None
440
440
  return op_id
441
441
 
442
- async def _void_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
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:
443
454
  """Reverse an accepted charge whose items were never stored, then unwind locally.
444
455
 
445
456
  POST /v2/actor-runs/{run}/charge/void with the charge's Idempotency-Key.
@@ -458,13 +469,19 @@ class ChargingManager:
458
469
  return True
459
470
  except Exception as err: # noqa: BLE001
460
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
461
478
  # 404: nothing to void; 409: already settled; 4xx auth: permanent.
462
479
  if status is not None and 400 <= status < 500:
463
480
  return False
464
481
  if attempt + 1 < self._POST_ATTEMPTS:
465
482
  await asyncio.sleep(0.25 * (attempt + 1))
466
483
  # Outcome unknown: keep the reservation and retry before the next push / at exit.
467
- self._pending_voids.append((event_name, operation_id, reserved))
484
+ self._pending_voids.append((event_name, operation_id, reserved, not_found_means_voided))
468
485
  logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
469
486
  return False
470
487
 
@@ -474,8 +491,8 @@ class ChargingManager:
474
491
  Returns False while any earlier void is still unconfirmed.
475
492
  """
476
493
  pending, self._pending_voids = self._pending_voids, []
477
- for event_name, operation_id, reserved in pending:
478
- await self._void_charge(event_name, operation_id, reserved)
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)
479
496
  return not self._pending_voids
480
497
 
481
498
  def _quarantine_stale_pending(self) -> None:
@@ -1158,19 +1175,39 @@ async def push_data_and_charge(
1158
1175
  # Synthetic storage event first (local-only); then explicit billable event.
1159
1176
  reserved_in_op: list[tuple[str, int]] = []
1160
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
+
1161
1206
  try:
1162
1207
  if default_event:
1163
- charging_manager._take_last_accepted_operation_id()
1164
- r = await charging_manager._charge_exclusive(default_event, len(limited_items))
1165
- results.append(r)
1166
- reserved_in_op.append((default_event, r.charged_count))
1167
- operation_ids.append(charging_manager._take_last_accepted_operation_id())
1208
+ await charge_one(default_event)
1168
1209
  if event_name and event_name != default_event:
1169
- charging_manager._take_last_accepted_operation_id()
1170
- r = await charging_manager._charge_exclusive(event_name, len(limited_items))
1171
- results.append(r)
1172
- reserved_in_op.append((event_name, r.charged_count))
1173
- operation_ids.append(charging_manager._take_last_accepted_operation_id())
1210
+ await charge_one(event_name)
1174
1211
  except Exception:
1175
1212
  for name, reserved in reserved_in_op:
1176
1213
  if reserved > 0:
@@ -1192,11 +1229,31 @@ async def push_data_and_charge(
1192
1229
  )
1193
1230
  # Unwind any event that reserved more than we will persist
1194
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
+ }
1195
1235
  for name, result in charged_events:
1196
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
1197
1241
  charging_manager._unwind_reservation(
1198
1242
  name, result.charged_count - accepted_for_items
1199
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)
1200
1257
  if accepted_for_items > 0:
1201
1258
  try:
1202
1259
  await push_fn(limited_items[:accepted_for_items])
@@ -1218,7 +1275,10 @@ async def push_data_and_charge(
1218
1275
  kept = min(accepted_for_items, next(r.charged_count for n, r in charged_events if n == name))
1219
1276
  if kept <= 0:
1220
1277
  continue
1221
- if op_id:
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:
1222
1282
  await charging_manager._void_charge(name, op_id, kept)
1223
1283
  elif is_default_dataset_item_event(name):
1224
1284
  charging_manager._unwind_reservation(name, kept)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.3
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
@@ -659,3 +659,86 @@ async def test_unpriced_explicit_event_with_priced_default_is_gated() -> None:
659
659
 
660
660
  with pytest.raises(RuntimeError, match='could not be voided yet'):
661
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'