scrapely-python 0.1.18__tar.gz → 0.1.19__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 (71) hide show
  1. {scrapely_python-0.1.18/src/scrapely_python.egg-info → scrapely_python-0.1.19}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/charging.py +54 -6
  5. {scrapely_python-0.1.18 → scrapely_python-0.1.19/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  6. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_charge_limit_guard.py +76 -0
  7. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/LICENSE +0 -0
  8. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/LICENSE-APACHE +0 -0
  9. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/NOTICE +0 -0
  10. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/README.md +0 -0
  11. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/setup.cfg +0 -0
  12. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/_browser_locations.py +0 -0
  13. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/actor.py +0 -0
  14. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/browser_configuration.py +0 -0
  15. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/cdp_retry.py +0 -0
  16. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/charging_client.py +0 -0
  17. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/configuration.py +0 -0
  18. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/crypto/__init__.py +0 -0
  19. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/crypto/_secrets.py +0 -0
  20. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/errors.py +0 -0
  21. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/events/__init__.py +0 -0
  22. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/events/_types.py +0 -0
  23. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/events/event_manager.py +0 -0
  24. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/log.py +0 -0
  25. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/patch_browser_pool.py +0 -0
  26. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/pricing_types.py +0 -0
  27. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/proxy_configuration.py +0 -0
  28. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/py.typed +0 -0
  29. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/request_loaders/__init__.py +0 -0
  30. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  31. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/solver.py +0 -0
  32. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/__init__.py +0 -0
  33. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/_file_system/__init__.py +0 -0
  34. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  35. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  36. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/_metadata.py +0 -0
  37. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/api_client.py +0 -0
  38. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/client.py +0 -0
  39. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/patch_storage.py +0 -0
  40. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  41. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  42. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  43. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  44. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/types.py +0 -0
  46. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage/utils.py +0 -0
  47. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storage_clients/__init__.py +0 -0
  48. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/storages/__init__.py +0 -0
  49. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/types.py +0 -0
  50. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely/webhook.py +0 -0
  51. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  52. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  53. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely_python.egg-info/requires.txt +0 -0
  54. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/src/scrapely_python.egg-info/top_level.txt +0 -0
  55. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_api_client_retries.py +0 -0
  56. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_apify_parity.py +0 -0
  57. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_browser_configuration.py +0 -0
  58. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_cdp_retry.py +0 -0
  59. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_charge_retries.py +0 -0
  60. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_charging_parity.py +0 -0
  61. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_charging_pending.py +0 -0
  62. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_input_proxy_aliases.py +0 -0
  63. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_parity.py +0 -0
  64. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_patch_browser_pool.py +0 -0
  65. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_proxy_configuration.py +0 -0
  66. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_push_idempotency.py +0 -0
  67. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_run_control.py +0 -0
  68. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_secrets.py +0 -0
  69. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_solver.py +0 -0
  70. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_storage.py +0 -0
  71. {scrapely_python-0.1.18 → scrapely_python-0.1.19}/tests/test_terminal_status_message.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.18
3
+ Version: 0.1.19
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 AND Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.18"
7
+ version = "0.1.19"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT AND Apache-2.0"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.18'
135
+ __version__ = '0.1.19'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -240,6 +240,12 @@ class ChargingManager:
240
240
  self._charged_events: Dict[str, int] = {}
241
241
  self._max_total_charge_usd: Optional[Decimal] = None
242
242
  self._total_charged_usd: Decimal = Decimal('0.0')
243
+ # Run spend the platform counts against maxTotalChargeUsd that this SDK
244
+ # does not charge itself: compute/proxy/browser usage and server-billed
245
+ # events. Learnt from the charge endpoint's remainingBudgetNd (only ever
246
+ # grows), so the local budget gates on the run's true remaining budget.
247
+ self._platform_other_usd: Decimal = Decimal('0')
248
+ self._last_remaining_budget_nd: Optional[int] = None
243
249
  self._charge_lock = RLock()
244
250
  self._flush_lock = asyncio.Lock()
245
251
  # Serializes budget calculation/reservation with PPE-aware dataset pushes.
@@ -288,6 +294,27 @@ class ChargingManager:
288
294
  self._refused_default_dataset_items,
289
295
  )
290
296
 
297
+ def _remaining_budget_usd(self) -> Decimal:
298
+ """maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
299
+ assert self._max_total_charge_usd is not None
300
+ return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
301
+
302
+ def _sync_platform_budget(self) -> None:
303
+ """Fold the last charge response's remainingBudgetNd into the local budget.
304
+
305
+ Called after the local reservation matches what the platform accepted.
306
+ The platform's remaining budget = max - (our charges + usage + server
307
+ events), so ``other`` = max - remaining - our charges. Monotonic: usage
308
+ never shrinks, and a stale answer must not hand budget back.
309
+ """
310
+ nd = self._last_remaining_budget_nd
311
+ self._last_remaining_budget_nd = None
312
+ if nd is None or self._max_total_charge_usd is None or nd < 0:
313
+ return
314
+ other = self._max_total_charge_usd - Decimal(nd) / Decimal(1_000_000_000) - self._total_charged_usd
315
+ if other > self._platform_other_usd:
316
+ self._platform_other_usd = other
317
+
291
318
  def is_charge_limit_reached(self) -> bool:
292
319
  """Whether this pay-per-event run has used up its spending limit.
293
320
 
@@ -307,7 +334,7 @@ class ChargingManager:
307
334
  prices = [price for price in self._per_event_prices.values() if price > 0]
308
335
  if not prices:
309
336
  return False
310
- return self._max_total_charge_usd - self._total_charged_usd < min(prices)
337
+ return self._remaining_budget_usd() < min(prices)
311
338
 
312
339
  def _mark_charge_limit_hit(self) -> None:
313
340
  if self._pricing_info is not None:
@@ -795,6 +822,7 @@ class ChargingManager:
795
822
  idempotency_key=idempotency_key,
796
823
  deadline=deadline,
797
824
  )
825
+ self._last_remaining_budget_nd = _read_remaining_budget_nd(body)
798
826
  return _read_accepted_count(body, count)
799
827
  except Exception as e:
800
828
  last_error = e
@@ -957,7 +985,7 @@ class ChargingManager:
957
985
  # legacy one-event overdraw while the budget is not yet exceeded.
958
986
  chargeable_count = count
959
987
  if self._max_total_charge_usd is not None and affordability_price > 0:
960
- remaining = self._max_total_charge_usd - self._total_charged_usd
988
+ remaining = self._remaining_budget_usd()
961
989
  max_count_by_limit = max(0, int(remaining / affordability_price))
962
990
  if count > max_count_by_limit:
963
991
  chargeable_count = (
@@ -1045,6 +1073,15 @@ class ChargingManager:
1045
1073
  charged_count=chargeable_count,
1046
1074
  event_charge_limit_reached=self._is_event_limit_reached(event_name),
1047
1075
  )
1076
+ self._sync_platform_budget()
1077
+ if self._max_total_charge_usd is not None and self._platform_other_usd > 0:
1078
+ # Usage may have used up what is left: report it on this result.
1079
+ result = ChargeResult(
1080
+ chargeable_within_limit=self._compute_chargeable(),
1081
+ charged_count=result.charged_count,
1082
+ event_charge_limit_reached=result.event_charge_limit_reached
1083
+ or self._is_event_limit_reached(event_name),
1084
+ )
1048
1085
  else:
1049
1086
  raise ChargeUncertainError(
1050
1087
  f'Charge for event {event_name!r} (count={chargeable_count}) '
@@ -1103,7 +1140,7 @@ class ChargingManager:
1103
1140
  if event_price is None or event_price <= 0:
1104
1141
  return None
1105
1142
 
1106
- remaining = self._max_total_charge_usd - self._total_charged_usd
1143
+ remaining = self._remaining_budget_usd()
1107
1144
  return max(0, int(remaining / event_price))
1108
1145
 
1109
1146
  def calculate_total_charged_amount(self) -> Decimal:
@@ -1165,7 +1202,7 @@ class ChargingManager:
1165
1202
  if self._max_total_charge_usd is None:
1166
1203
  return items_count
1167
1204
 
1168
- remaining = self._max_total_charge_usd - self._total_charged_usd
1205
+ remaining = self._remaining_budget_usd()
1169
1206
  max_items = int(remaining / combined_price)
1170
1207
 
1171
1208
  return min(items_count, max(0, max_items))
@@ -1226,7 +1263,7 @@ class ChargingManager:
1226
1263
  if self._max_total_charge_usd is None:
1227
1264
  return False
1228
1265
 
1229
- remaining = self._max_total_charge_usd - self._total_charged_usd
1266
+ remaining = self._remaining_budget_usd()
1230
1267
  return remaining < event_price
1231
1268
 
1232
1269
  def _compute_chargeable(self) -> Dict[str, Optional[int]]:
@@ -1264,6 +1301,17 @@ def _is_charge_limit_rejection(err: ChargeRejectedError) -> bool:
1264
1301
  )
1265
1302
 
1266
1303
 
1304
+ def _read_remaining_budget_nd(body: Any) -> Optional[int]:
1305
+ """remainingBudgetNd from a charge response (platforms that send it), else None."""
1306
+ if not isinstance(body, dict):
1307
+ return None
1308
+ data = body.get('data') if isinstance(body.get('data'), dict) else body
1309
+ raw = data.get('remainingBudgetNd') if isinstance(data, dict) else None
1310
+ if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw >= 0:
1311
+ return int(raw)
1312
+ return None
1313
+
1314
+
1267
1315
  def _read_accepted_count(body: Any, fallback: int) -> int:
1268
1316
  if not isinstance(body, dict):
1269
1317
  return fallback
@@ -1653,7 +1701,7 @@ async def push_data_with_charges(
1653
1701
  )
1654
1702
  rows = len(rows_in)
1655
1703
  if charging_manager._max_total_charge_usd is not None and row_cost > 0:
1656
- remaining = charging_manager._max_total_charge_usd - charging_manager._total_charged_usd
1704
+ remaining = charging_manager._remaining_budget_usd()
1657
1705
  rows = min(rows, max(0, int(remaining / row_cost)))
1658
1706
 
1659
1707
  ops: List[tuple] = [] # (event, charged, op_id, uncertain)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.18
3
+ Version: 0.1.19
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 AND Apache-2.0
@@ -509,3 +509,79 @@ def test_multi_event_validation() -> None:
509
509
  for bad in ({}, {'result': 0}, {'result': 1.5}, {'apify-default-dataset-item': 1}, {'': 1}, None):
510
510
  with pytest.raises(ValueError):
511
511
  _validate_row_charges(bad)
512
+
513
+
514
+ # ---- platform usage counts against the cap (remainingBudgetNd in charge responses) ----
515
+
516
+
517
+ class UsageApi(FakeChargeApi):
518
+ """Ledger whose cap also covers platform usage, reporting remainingBudgetNd like the server."""
519
+
520
+ def __init__(self, *, price: Decimal, max_usd: Decimal, usage: Decimal, send_remaining: bool = True) -> None:
521
+ super().__init__(price=price, max_usd=max_usd)
522
+ self.usage = usage
523
+ self.send_remaining = send_remaining
524
+
525
+ async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, **_: Any) -> Dict[str, Any]:
526
+ count = int((json_data or {}).get('count', 1))
527
+ left = self.max_usd - self.spent - self.usage
528
+ affordable = int(left / self.price)
529
+ if affordable < 1:
530
+ raise HttpError(402, 'charge-limit-exceeded')
531
+ accepted = min(count, affordable)
532
+ self.spent += self.price * accepted
533
+ body: Dict[str, Any] = {'acceptedCount': accepted, 'requestedCount': count}
534
+ if self.send_remaining:
535
+ body['remainingBudgetNd'] = int((self.max_usd - self.spent - self.usage) * 1_000_000_000)
536
+ return body
537
+
538
+
539
+ @pytest.mark.asyncio
540
+ async def test_platform_usage_shrinks_the_local_budget() -> None:
541
+ api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('0.05'), usage=Decimal('0.03'))
542
+ mgr = _manager(api, max_usd='0.05')
543
+ store = Store()
544
+
545
+ r = await mgr.charge('result', 1)
546
+ assert r.charged_count == 1
547
+ # $0.05 cap - $0.01 charged - $0.03 usage: one more event fits, not four.
548
+ assert mgr.calculate_max_event_charge_count_within_limit('result') == 1
549
+ assert mgr.is_charge_limit_reached() is False
550
+ pushed = await _charged_push(mgr, store, [{'n': n} for n in range(5)])
551
+ assert pushed.charged_count == 1 and pushed.event_charge_limit_reached is True
552
+ assert len(store.items) == 1
553
+ assert mgr.is_charge_limit_reached() is True
554
+ # The prepaid row from the first charge is still saved; nothing beyond it.
555
+ await _plain_push(mgr, store, [{'p': 1}, {'p': 2}])
556
+ assert len(store.items) == 2
557
+
558
+
559
+ @pytest.mark.asyncio
560
+ async def test_usage_learnt_from_a_charge_marks_the_limit_on_that_result() -> None:
561
+ api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('0.05'), usage=Decimal('0.04'))
562
+ mgr = _manager(api, max_usd='0.05')
563
+ r = await mgr.charge('result', 1)
564
+ assert r.charged_count == 1
565
+ assert r.event_charge_limit_reached is True
566
+ assert mgr.is_charge_limit_reached() is True
567
+
568
+
569
+ @pytest.mark.asyncio
570
+ async def test_without_remaining_budget_in_responses_behaviour_is_unchanged() -> None:
571
+ api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('0.05'), usage=Decimal('0.03'), send_remaining=False)
572
+ mgr = _manager(api, max_usd='0.05')
573
+ await mgr.charge('result', 1)
574
+ assert mgr.calculate_max_event_charge_count_within_limit('result') == 4
575
+ assert mgr._platform_other_usd == 0
576
+
577
+
578
+ @pytest.mark.asyncio
579
+ async def test_platform_other_spend_never_shrinks() -> None:
580
+ api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('1'), usage=Decimal('0.30'))
581
+ mgr = _manager(api, max_usd='1')
582
+ await mgr.charge('result', 1)
583
+ seen = mgr._platform_other_usd
584
+ assert seen == Decimal('0.30')
585
+ api.usage = Decimal('0.10') # a stale or lower report
586
+ await mgr.charge('result', 1)
587
+ assert mgr._platform_other_usd == seen