scrapely-python 0.1.18__tar.gz → 0.1.20__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.20}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/charging.py +75 -9
  5. {scrapely_python-0.1.18 → scrapely_python-0.1.20/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  6. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charge_limit_guard.py +138 -0
  7. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/LICENSE +0 -0
  8. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/LICENSE-APACHE +0 -0
  9. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/NOTICE +0 -0
  10. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/README.md +0 -0
  11. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/setup.cfg +0 -0
  12. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/_browser_locations.py +0 -0
  13. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/actor.py +0 -0
  14. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/browser_configuration.py +0 -0
  15. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/cdp_retry.py +0 -0
  16. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/charging_client.py +0 -0
  17. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/configuration.py +0 -0
  18. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/crypto/__init__.py +0 -0
  19. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/crypto/_secrets.py +0 -0
  20. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/errors.py +0 -0
  21. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/events/__init__.py +0 -0
  22. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/events/_types.py +0 -0
  23. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/events/event_manager.py +0 -0
  24. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/log.py +0 -0
  25. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/patch_browser_pool.py +0 -0
  26. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/pricing_types.py +0 -0
  27. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/proxy_configuration.py +0 -0
  28. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/py.typed +0 -0
  29. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/request_loaders/__init__.py +0 -0
  30. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  31. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/solver.py +0 -0
  32. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/__init__.py +0 -0
  33. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_file_system/__init__.py +0 -0
  34. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  35. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  36. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_metadata.py +0 -0
  37. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/api_client.py +0 -0
  38. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/client.py +0 -0
  39. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/patch_storage.py +0 -0
  40. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  41. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  42. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  43. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  44. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/types.py +0 -0
  46. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/utils.py +0 -0
  47. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage_clients/__init__.py +0 -0
  48. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storages/__init__.py +0 -0
  49. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/types.py +0 -0
  50. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/webhook.py +0 -0
  51. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  52. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  53. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/requires.txt +0 -0
  54. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/top_level.txt +0 -0
  55. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_api_client_retries.py +0 -0
  56. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_apify_parity.py +0 -0
  57. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_browser_configuration.py +0 -0
  58. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_cdp_retry.py +0 -0
  59. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charge_retries.py +0 -0
  60. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charging_parity.py +0 -0
  61. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charging_pending.py +0 -0
  62. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_input_proxy_aliases.py +0 -0
  63. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_parity.py +0 -0
  64. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_patch_browser_pool.py +0 -0
  65. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_proxy_configuration.py +0 -0
  66. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_push_idempotency.py +0 -0
  67. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_run_control.py +0 -0
  68. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_secrets.py +0 -0
  69. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_solver.py +0 -0
  70. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_storage.py +0 -0
  71. {scrapely_python-0.1.18 → scrapely_python-0.1.20}/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.20
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.20"
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.20'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -240,6 +240,14 @@ 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
249
+ # True once the platform reported a remaining budget (its usage is then in the local budget).
250
+ self._platform_budget_seen = False
243
251
  self._charge_lock = RLock()
244
252
  self._flush_lock = asyncio.Lock()
245
253
  # Serializes budget calculation/reservation with PPE-aware dataset pushes.
@@ -288,6 +296,28 @@ class ChargingManager:
288
296
  self._refused_default_dataset_items,
289
297
  )
290
298
 
299
+ def _remaining_budget_usd(self) -> Decimal:
300
+ """maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
301
+ assert self._max_total_charge_usd is not None
302
+ return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
303
+
304
+ def _sync_platform_budget(self) -> None:
305
+ """Fold the last charge response's remainingBudgetNd into the local budget.
306
+
307
+ Called after the local reservation matches what the platform accepted.
308
+ The platform's remaining budget = max - (our charges + usage + server
309
+ events), so ``other`` = max - remaining - our charges. Monotonic: usage
310
+ never shrinks, and a stale answer must not hand budget back.
311
+ """
312
+ nd = self._last_remaining_budget_nd
313
+ self._last_remaining_budget_nd = None
314
+ if nd is None or self._max_total_charge_usd is None or nd < 0:
315
+ return
316
+ self._platform_budget_seen = True
317
+ other = self._max_total_charge_usd - Decimal(nd) / Decimal(1_000_000_000) - self._total_charged_usd
318
+ if other > self._platform_other_usd:
319
+ self._platform_other_usd = other
320
+
291
321
  def is_charge_limit_reached(self) -> bool:
292
322
  """Whether this pay-per-event run has used up its spending limit.
293
323
 
@@ -307,7 +337,7 @@ class ChargingManager:
307
337
  prices = [price for price in self._per_event_prices.values() if price > 0]
308
338
  if not prices:
309
339
  return False
310
- return self._max_total_charge_usd - self._total_charged_usd < min(prices)
340
+ return self._remaining_budget_usd() < min(prices)
311
341
 
312
342
  def _mark_charge_limit_hit(self) -> None:
313
343
  if self._pricing_info is not None:
@@ -795,6 +825,7 @@ class ChargingManager:
795
825
  idempotency_key=idempotency_key,
796
826
  deadline=deadline,
797
827
  )
828
+ self._last_remaining_budget_nd = _read_remaining_budget_nd(body)
798
829
  return _read_accepted_count(body, count)
799
830
  except Exception as e:
800
831
  last_error = e
@@ -957,7 +988,7 @@ class ChargingManager:
957
988
  # legacy one-event overdraw while the budget is not yet exceeded.
958
989
  chargeable_count = count
959
990
  if self._max_total_charge_usd is not None and affordability_price > 0:
960
- remaining = self._max_total_charge_usd - self._total_charged_usd
991
+ remaining = self._remaining_budget_usd()
961
992
  max_count_by_limit = max(0, int(remaining / affordability_price))
962
993
  if count > max_count_by_limit:
963
994
  chargeable_count = (
@@ -1045,6 +1076,15 @@ class ChargingManager:
1045
1076
  charged_count=chargeable_count,
1046
1077
  event_charge_limit_reached=self._is_event_limit_reached(event_name),
1047
1078
  )
1079
+ self._sync_platform_budget()
1080
+ if self._max_total_charge_usd is not None and self._platform_other_usd > 0:
1081
+ # Usage may have used up what is left: report it on this result.
1082
+ result = ChargeResult(
1083
+ chargeable_within_limit=self._compute_chargeable(),
1084
+ charged_count=result.charged_count,
1085
+ event_charge_limit_reached=result.event_charge_limit_reached
1086
+ or self._is_event_limit_reached(event_name),
1087
+ )
1048
1088
  else:
1049
1089
  raise ChargeUncertainError(
1050
1090
  f'Charge for event {event_name!r} (count={chargeable_count}) '
@@ -1103,7 +1143,7 @@ class ChargingManager:
1103
1143
  if event_price is None or event_price <= 0:
1104
1144
  return None
1105
1145
 
1106
- remaining = self._max_total_charge_usd - self._total_charged_usd
1146
+ remaining = self._remaining_budget_usd()
1107
1147
  return max(0, int(remaining / event_price))
1108
1148
 
1109
1149
  def calculate_total_charged_amount(self) -> Decimal:
@@ -1165,7 +1205,7 @@ class ChargingManager:
1165
1205
  if self._max_total_charge_usd is None:
1166
1206
  return items_count
1167
1207
 
1168
- remaining = self._max_total_charge_usd - self._total_charged_usd
1208
+ remaining = self._remaining_budget_usd()
1169
1209
  max_items = int(remaining / combined_price)
1170
1210
 
1171
1211
  return min(items_count, max(0, max_items))
@@ -1226,7 +1266,7 @@ class ChargingManager:
1226
1266
  if self._max_total_charge_usd is None:
1227
1267
  return False
1228
1268
 
1229
- remaining = self._max_total_charge_usd - self._total_charged_usd
1269
+ remaining = self._remaining_budget_usd()
1230
1270
  return remaining < event_price
1231
1271
 
1232
1272
  def _compute_chargeable(self) -> Dict[str, Optional[int]]:
@@ -1264,6 +1304,17 @@ def _is_charge_limit_rejection(err: ChargeRejectedError) -> bool:
1264
1304
  )
1265
1305
 
1266
1306
 
1307
+ def _read_remaining_budget_nd(body: Any) -> Optional[int]:
1308
+ """remainingBudgetNd from a charge response (platforms that send it), else None."""
1309
+ if not isinstance(body, dict):
1310
+ return None
1311
+ data = body.get('data') if isinstance(body.get('data'), dict) else body
1312
+ raw = data.get('remainingBudgetNd') if isinstance(data, dict) else None
1313
+ if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw >= 0:
1314
+ return int(raw)
1315
+ return None
1316
+
1317
+
1267
1318
  def _read_accepted_count(body: Any, fallback: int) -> int:
1268
1319
  if not isinstance(body, dict):
1269
1320
  return fallback
@@ -1653,7 +1704,7 @@ async def push_data_with_charges(
1653
1704
  )
1654
1705
  rows = len(rows_in)
1655
1706
  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
1707
+ remaining = charging_manager._remaining_budget_usd()
1657
1708
  rows = min(rows, max(0, int(remaining / row_cost)))
1658
1709
 
1659
1710
  ops: List[tuple] = [] # (event, charged, op_id, uncertain)
@@ -1669,7 +1720,7 @@ async def push_data_with_charges(
1669
1720
  else:
1670
1721
  await charging_manager._void_charge(name, op_id, charged)
1671
1722
 
1672
- for _attempt in range(4):
1723
+ for _attempt in range(6):
1673
1724
  if rows <= 0:
1674
1725
  break
1675
1726
  ops = []
@@ -1693,10 +1744,25 @@ async def push_data_with_charges(
1693
1744
  if short_to is None:
1694
1745
  break
1695
1746
  # Some event came back short: void this attempt in full and retry with
1696
- # the rows that event can pay for (a void restores the budget).
1747
+ # the whole rows the budget covers at the FULL per-row cost. Sizing
1748
+ # from the short event alone ignores the other events of the row, so
1749
+ # the retry came back short again (appstore 64df0a3608d74af88: 20
1750
+ # details -> 14, retry sized 14, app-item short, 0 saved). What this
1751
+ # attempt was accepted is budget the void gives back; with the
1752
+ # platform's remainingBudgetNd the local budget also knows the rest.
1753
+ attempt_spent = sum(
1754
+ (charging_manager._calculate_event_price(name) or Decimal('0')) * Decimal(charged)
1755
+ for name, charged, _op, _unc in ops
1756
+ )
1697
1757
  await undo(ops)
1698
1758
  ops = []
1699
- rows = short_to # got < n * rows, so short_to < rows
1759
+ fit = int(attempt_spent / row_cost) if row_cost > 0 else 0
1760
+ if charging_manager._platform_budget_seen and charging_manager._max_total_charge_usd is not None and row_cost > 0:
1761
+ fit = max(fit, int(charging_manager._remaining_budget_usd() / row_cost))
1762
+ # At least one more single-row try: without the platform's remaining
1763
+ # budget the leftover after the short answer is unknown, and the last
1764
+ # affordable row may still fit.
1765
+ rows = min(rows - 1, max(fit, 1))
1700
1766
 
1701
1767
  if rows <= 0 or not ops:
1702
1768
  await undo(ops)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.18
3
+ Version: 0.1.20
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,141 @@ 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
588
+
589
+
590
+
591
+ # ---- multi-event retry sizing: whole rows at the FULL per-row cost ----
592
+
593
+ APP_PRICES = {'app-details': Decimal('0.00015'), 'app-item': Decimal('0.00005')} # row = $0.0002
594
+
595
+
596
+ class RemainingMultiApi(MultiApi):
597
+ """MultiApi that also reports remainingBudgetNd (server #725)."""
598
+
599
+ async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kw: Any) -> Dict[str, Any]:
600
+ body = await super().post(path, json_data=json_data, headers=headers, **kw)
601
+ if not path.endswith('/charge/void') and self.max_usd is not None:
602
+ body = {**body, 'remainingBudgetNd': int((self.max_usd - self.spent) * 1_000_000_000)}
603
+ return body
604
+
605
+
606
+ def _app(server_max: str, remaining: bool = False):
607
+ api = (RemainingMultiApi if remaining else MultiApi)(APP_PRICES, Decimal(server_max))
608
+ return api, _manager(api, prices=dict(APP_PRICES), max_usd='1')
609
+
610
+
611
+ @pytest.mark.asyncio
612
+ async def test_retry_after_one_short_event_is_sized_by_the_full_row_cost() -> None:
613
+ """appstore 64df0a3608d74af88: details 20 -> 14 accepted; the retry must not size from 14."""
614
+ api, mgr = _app('0.0021')
615
+ store = Store()
616
+ r = await push_data_with_charges(mgr, [{'n': n} for n in range(20)], {'app-details': 1, 'app-item': 1}, True, store.push)
617
+ assert r.charged_count == 10 # $0.0021 / $0.0002 per full row
618
+ assert len(store.items) == 10
619
+ assert api.spent == Decimal('0.0020')
620
+ assert sorted(api.ops.values()) == [('app-details', 10), ('app-item', 10)]
621
+
622
+
623
+ @pytest.mark.asyncio
624
+ async def test_retry_when_several_events_come_back_short() -> None:
625
+ """Both events of the row run short on the way down; it still converges on whole paid rows."""
626
+ api, mgr = _multi('1', server_max='0.0405') # 2.53 rows of $0.016 (1 result + 3 pages)
627
+ store = Store()
628
+ r = await push_data_with_charges(mgr, [{'n': n} for n in range(6)], {'result': 1, 'page': 3}, True, store.push)
629
+ assert r.charged_count == 2
630
+ assert len(store.items) == 2
631
+ assert api.spent == Decimal('0.032')
632
+
633
+
634
+ @pytest.mark.asyncio
635
+ async def test_last_affordable_row_is_saved_without_remaining_budget() -> None:
636
+ api, mgr = _app('0.0002') # exactly one row; the short answer hides the leftover
637
+ store = Store()
638
+ r = await push_data_with_charges(mgr, [{'n': n} for n in range(5)], {'app-details': 1, 'app-item': 1}, True, store.push)
639
+ assert r.charged_count == 1 and len(store.items) == 1
640
+ assert api.spent == Decimal('0.0002')
641
+
642
+
643
+ @pytest.mark.asyncio
644
+ async def test_platform_remaining_budget_sizes_the_retry_exactly() -> None:
645
+ api, mgr = _app('0.00215', remaining=True) # 10.75 rows
646
+ store = Store()
647
+ r = await push_data_with_charges(mgr, [{'n': n} for n in range(20)], {'app-details': 1, 'app-item': 1}, True, store.push)
648
+ assert r.charged_count == 10 and len(store.items) == 10
649
+ assert api.spent == Decimal('0.0020')