scrapely-python 0.1.19__tar.gz → 0.1.21__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.19/src/scrapely_python.egg-info → scrapely_python-0.1.21}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/actor.py +8 -0
  5. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/charging.py +39 -10
  6. {scrapely_python-0.1.19 → scrapely_python-0.1.21/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  7. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_charge_limit_guard.py +143 -0
  8. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/LICENSE +0 -0
  9. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/LICENSE-APACHE +0 -0
  10. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/NOTICE +0 -0
  11. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/README.md +0 -0
  12. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/setup.cfg +0 -0
  13. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/_browser_locations.py +0 -0
  14. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/browser_configuration.py +0 -0
  15. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/cdp_retry.py +0 -0
  16. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/charging_client.py +0 -0
  17. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/configuration.py +0 -0
  18. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/crypto/__init__.py +0 -0
  19. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/crypto/_secrets.py +0 -0
  20. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/errors.py +0 -0
  21. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/events/__init__.py +0 -0
  22. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/events/_types.py +0 -0
  23. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/events/event_manager.py +0 -0
  24. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/log.py +0 -0
  25. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/patch_browser_pool.py +0 -0
  26. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/pricing_types.py +0 -0
  27. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/proxy_configuration.py +0 -0
  28. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/py.typed +0 -0
  29. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/request_loaders/__init__.py +0 -0
  30. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  31. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/solver.py +0 -0
  32. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/__init__.py +0 -0
  33. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/_file_system/__init__.py +0 -0
  34. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  35. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  36. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/_metadata.py +0 -0
  37. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/api_client.py +0 -0
  38. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/client.py +0 -0
  39. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/patch_storage.py +0 -0
  40. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  41. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  42. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  43. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  44. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/types.py +0 -0
  46. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage/utils.py +0 -0
  47. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storage_clients/__init__.py +0 -0
  48. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/storages/__init__.py +0 -0
  49. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/types.py +0 -0
  50. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely/webhook.py +0 -0
  51. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  52. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  53. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely_python.egg-info/requires.txt +0 -0
  54. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/src/scrapely_python.egg-info/top_level.txt +0 -0
  55. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_api_client_retries.py +0 -0
  56. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_apify_parity.py +0 -0
  57. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_browser_configuration.py +0 -0
  58. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_cdp_retry.py +0 -0
  59. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_charge_retries.py +0 -0
  60. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_charging_parity.py +0 -0
  61. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_charging_pending.py +0 -0
  62. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_input_proxy_aliases.py +0 -0
  63. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_parity.py +0 -0
  64. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_patch_browser_pool.py +0 -0
  65. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_proxy_configuration.py +0 -0
  66. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_push_idempotency.py +0 -0
  67. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_run_control.py +0 -0
  68. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_secrets.py +0 -0
  69. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_solver.py +0 -0
  70. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/tests/test_storage.py +0 -0
  71. {scrapely_python-0.1.19 → scrapely_python-0.1.21}/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.19
3
+ Version: 0.1.21
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.19"
7
+ version = "0.1.21"
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.19'
135
+ __version__ = '0.1.21'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -783,6 +783,7 @@ class Actor(metaclass=_ActorMeta):
783
783
  *,
784
784
  event_name: Optional[str] = None,
785
785
  charges: Optional[Dict[str, int]] = None,
786
+ charge_per: str = 'row',
786
787
  ) -> ChargeResult:
787
788
  """Push data to the default dataset, charging pay-per-event items.
788
789
 
@@ -802,6 +803,10 @@ class Actor(metaclass=_ActorMeta):
802
803
  charges 1 ``result`` and 3 ``page`` events for every row. A row is stored
803
804
  only when all of its events were charged; rows the spending limit cannot
804
805
  pay for in full are not stored. Cannot be combined with ``charged_event_name``.
806
+ charge_per: ``'row'`` (default) charges ``charges`` for every row; ``'batch'``
807
+ charges them once for the whole push, e.g.
808
+ ``push_data(page_rows, charges={'document': 1}, charge_per='batch')``:
809
+ all rows are stored if the charge is accepted, none if it is refused.
805
810
 
806
811
  Returns:
807
812
  ChargeResult describing how many items were charged and pushed.
@@ -833,7 +838,10 @@ class Actor(metaclass=_ActorMeta):
833
838
  charges,
834
839
  True, # Actor.push_data always targets the default dataset
835
840
  cls._default_dataset.push_data,
841
+ charge_per=charge_per,
836
842
  )
843
+ if charge_per != 'row':
844
+ raise ValueError("charge_per needs charges={...}")
837
845
  return await push_data_and_charge(
838
846
  charging_manager,
839
847
  items,
@@ -246,6 +246,8 @@ class ChargingManager:
246
246
  # grows), so the local budget gates on the run's true remaining budget.
247
247
  self._platform_other_usd: Decimal = Decimal('0')
248
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
249
251
  self._charge_lock = RLock()
250
252
  self._flush_lock = asyncio.Lock()
251
253
  # Serializes budget calculation/reservation with PPE-aware dataset pushes.
@@ -311,6 +313,7 @@ class ChargingManager:
311
313
  self._last_remaining_budget_nd = None
312
314
  if nd is None or self._max_total_charge_usd is None or nd < 0:
313
315
  return
316
+ self._platform_budget_seen = True
314
317
  other = self._max_total_charge_usd - Decimal(nd) / Decimal(1_000_000_000) - self._total_charged_usd
315
318
  if other > self._platform_other_usd:
316
319
  self._platform_other_usd = other
@@ -1654,9 +1657,15 @@ async def push_data_with_charges(
1654
1657
  charges: Dict[str, int],
1655
1658
  is_default_dataset: bool,
1656
1659
  push_fn: Any,
1660
+ charge_per: str = 'row',
1657
1661
  ) -> ChargeResult:
1658
1662
  """Push rows that each cost several events, atomically per row.
1659
1663
 
1664
+ ``charge_per='batch'``: ``charges`` are charged ONCE for the whole push (e.g. one
1665
+ ``document`` for all of its page rows). If every event is accepted all rows are
1666
+ stored, otherwise none (and nothing stays billed). The prepaid-row balance is
1667
+ neither used nor changed.
1668
+
1660
1669
  ``charges`` maps event name -> events per row, e.g. ``{'result': 1, 'page': 3}``.
1661
1670
  A row is stored only when every one of its events was charged: the full set
1662
1671
  is charged first, rows the budget cannot fully pay for are not stored, and
@@ -1666,6 +1675,9 @@ async def push_data_with_charges(
1666
1675
  dataset the default-dataset-item event (when priced) is added per row.
1667
1676
  """
1668
1677
  charges = _validate_row_charges(charges)
1678
+ if charge_per not in ('row', 'batch'):
1679
+ raise ValueError("charge_per must be 'row' or 'batch'")
1680
+ batch = charge_per == 'batch'
1669
1681
  rows_in = items if isinstance(items, list) else [items]
1670
1682
  if not rows_in:
1671
1683
  return ChargeResult(chargeable_within_limit={}, charged_count=0, event_charge_limit_reached=False)
@@ -1688,7 +1700,8 @@ async def push_data_with_charges(
1688
1700
  resolve_default_dataset_item_event_name(charging_manager._per_event_prices)
1689
1701
  if default_event and default_event not in per_row and \
1690
1702
  (charging_manager._calculate_event_price(default_event) or Decimal('0')) > 0:
1691
- per_row[default_event] = 1
1703
+ # Per stored row, also for a batch.
1704
+ per_row[default_event] = len(rows_in) if batch else 1
1692
1705
  if not per_row:
1693
1706
  # Every listed event is free: a plain (unpaid) push.
1694
1707
  return await _unpaid_push_locked(charging_manager, rows_in, is_default_dataset, push_fn)
@@ -1699,7 +1712,8 @@ async def push_data_with_charges(
1699
1712
  row_cost = sum(
1700
1713
  (charging_manager._calculate_event_price(n) or Decimal('0')) * Decimal(c) for n, c in per_row.items()
1701
1714
  )
1702
- rows = len(rows_in)
1715
+ # Units charged for: rows, or the one batch (row_cost is then the batch's cost).
1716
+ rows = 1 if batch else len(rows_in)
1703
1717
  if charging_manager._max_total_charge_usd is not None and row_cost > 0:
1704
1718
  remaining = charging_manager._remaining_budget_usd()
1705
1719
  rows = min(rows, max(0, int(remaining / row_cost)))
@@ -1717,7 +1731,7 @@ async def push_data_with_charges(
1717
1731
  else:
1718
1732
  await charging_manager._void_charge(name, op_id, charged)
1719
1733
 
1720
- for _attempt in range(4):
1734
+ for _attempt in range(6):
1721
1735
  if rows <= 0:
1722
1736
  break
1723
1737
  ops = []
@@ -1741,10 +1755,25 @@ async def push_data_with_charges(
1741
1755
  if short_to is None:
1742
1756
  break
1743
1757
  # Some event came back short: void this attempt in full and retry with
1744
- # the rows that event can pay for (a void restores the budget).
1758
+ # the whole rows the budget covers at the FULL per-row cost. Sizing
1759
+ # from the short event alone ignores the other events of the row, so
1760
+ # the retry came back short again (appstore 64df0a3608d74af88: 20
1761
+ # details -> 14, retry sized 14, app-item short, 0 saved). What this
1762
+ # attempt was accepted is budget the void gives back; with the
1763
+ # platform's remainingBudgetNd the local budget also knows the rest.
1764
+ attempt_spent = sum(
1765
+ (charging_manager._calculate_event_price(name) or Decimal('0')) * Decimal(charged)
1766
+ for name, charged, _op, _unc in ops
1767
+ )
1745
1768
  await undo(ops)
1746
1769
  ops = []
1747
- rows = short_to # got < n * rows, so short_to < rows
1770
+ fit = int(attempt_spent / row_cost) if row_cost > 0 else 0
1771
+ if charging_manager._platform_budget_seen and charging_manager._max_total_charge_usd is not None and row_cost > 0:
1772
+ fit = max(fit, int(charging_manager._remaining_budget_usd() / row_cost))
1773
+ # At least one more single-row try: without the platform's remaining
1774
+ # budget the leftover after the short answer is unknown, and the last
1775
+ # affordable row may still fit.
1776
+ rows = min(rows - 1, max(fit, 1))
1748
1777
 
1749
1778
  if rows <= 0 or not ops:
1750
1779
  await undo(ops)
@@ -1757,11 +1786,11 @@ async def push_data_with_charges(
1757
1786
  event_charge_limit_reached=True,
1758
1787
  )
1759
1788
 
1760
- kept = rows_in[:rows]
1761
- if rows < len(rows_in):
1789
+ kept = rows_in if batch else rows_in[:rows]
1790
+ if len(kept) < len(rows_in):
1762
1791
  charging_manager._mark_charge_limit_hit()
1763
1792
  if is_default_dataset:
1764
- charging_manager._note_refused_default_dataset_items(len(rows_in) - rows)
1793
+ charging_manager._note_refused_default_dataset_items(len(rows_in) - len(kept))
1765
1794
  try:
1766
1795
  await _gated_push(push_fn, kept)
1767
1796
  except Exception as push_err:
@@ -1771,8 +1800,8 @@ async def push_data_with_charges(
1771
1800
  raise
1772
1801
  return ChargeResult(
1773
1802
  chargeable_within_limit=charging_manager.compute_chargeable(),
1774
- charged_count=rows,
1775
- event_charge_limit_reached=rows < len(rows_in) or charging_manager.is_charge_limit_reached(),
1803
+ charged_count=len(kept),
1804
+ event_charge_limit_reached=len(kept) < len(rows_in) or charging_manager.is_charge_limit_reached(),
1776
1805
  )
1777
1806
 
1778
1807
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.19
3
+ Version: 0.1.21
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
@@ -585,3 +585,146 @@ async def test_platform_other_spend_never_shrinks() -> None:
585
585
  api.usage = Decimal('0.10') # a stale or lower report
586
586
  await mgr.charge('result', 1)
587
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')
650
+
651
+
652
+ # ---- batch-charged push: charges={'document': 1}, charge_per='batch' ----
653
+
654
+ DOC = {'document': Decimal('0.01')}
655
+
656
+
657
+ def _doc(max_usd: str, server_max: Optional[str] = None, remaining: bool = False):
658
+ api = (RemainingMultiApi if remaining else MultiApi)(DOC, Decimal(server_max or max_usd))
659
+ return api, _manager(api, prices=dict(DOC), max_usd=max_usd)
660
+
661
+
662
+ def _pages(n: int):
663
+ return [{'page': i} for i in range(n)]
664
+
665
+
666
+ @pytest.mark.asyncio
667
+ async def test_batch_accepted_stores_every_row_for_one_charge() -> None:
668
+ api, mgr = _doc('1')
669
+ store = Store()
670
+ r = await push_data_with_charges(mgr, _pages(7), {'document': 1}, True, store.push, charge_per='batch')
671
+ assert r.charged_count == 7 and r.event_charge_limit_reached is False
672
+ assert len(store.items) == 7
673
+ assert api.spent == Decimal('0.01')
674
+ assert list(api.ops.values()) == [('document', 1)]
675
+ assert mgr._prepaid_row_balance == 0, 'batch push neither uses nor adds prepaid rows'
676
+
677
+
678
+ @pytest.mark.asyncio
679
+ async def test_batch_refused_stores_nothing() -> None:
680
+ api, mgr = _doc('0.005')
681
+ store = Store()
682
+ r = await push_data_with_charges(mgr, _pages(4), {'document': 1}, True, store.push, charge_per='batch')
683
+ assert r.charged_count == 0 and r.event_charge_limit_reached is True
684
+ assert store.items == [] and api.spent == 0
685
+
686
+
687
+ @pytest.mark.asyncio
688
+ async def test_batch_at_the_exact_budget_then_the_next_is_refused() -> None:
689
+ api, mgr = _doc('0.02')
690
+ store = Store()
691
+ for _ in range(2):
692
+ r = await push_data_with_charges(mgr, _pages(3), {'document': 1}, True, store.push, charge_per='batch')
693
+ assert r.charged_count == 3
694
+ assert mgr.is_charge_limit_reached() is True
695
+ r = await push_data_with_charges(mgr, _pages(3), {'document': 1}, True, store.push, charge_per='batch')
696
+ assert r.charged_count == 0
697
+ assert len(store.items) == 6 and api.spent == Decimal('0.02')
698
+
699
+
700
+ @pytest.mark.asyncio
701
+ async def test_batch_respects_the_usage_aware_budget() -> None:
702
+ # Local cap $1, but the platform (usage) only has $0.015 left: first batch fits, the second is refused
703
+ # and its partial charge does not leave rows stored.
704
+ api, mgr = _doc('1', server_max='0.015', remaining=True)
705
+ store = Store()
706
+ r1 = await push_data_with_charges(mgr, _pages(2), {'document': 1}, True, store.push, charge_per='batch')
707
+ assert r1.charged_count == 2
708
+ assert mgr._platform_budget_seen is True
709
+ r2 = await push_data_with_charges(mgr, _pages(2), {'document': 1}, True, store.push, charge_per='batch')
710
+ assert r2.charged_count == 0
711
+ assert len(store.items) == 2 and api.spent == Decimal('0.01')
712
+
713
+
714
+ @pytest.mark.asyncio
715
+ async def test_batch_with_the_default_dataset_item_event_charges_it_per_row() -> None:
716
+ prices = {'document': Decimal('0.01'), 'apify-default-dataset-item': Decimal('0.001')}
717
+ api = MultiApi(prices, Decimal('1'))
718
+ mgr = _manager(api, prices=dict(prices), max_usd='1')
719
+ store = Store()
720
+ r = await push_data_with_charges(mgr, _pages(5), {'document': 1}, True, store.push, charge_per='batch')
721
+ assert r.charged_count == 5
722
+ assert api.spent == Decimal('0.01') # the platform derives the dataset-item event itself
723
+ assert mgr.get_charged_events().get('apify-default-dataset-item') == 5, 'counted per stored row locally'
724
+
725
+
726
+ def test_charge_per_validation() -> None:
727
+ import asyncio
728
+ api, mgr = _doc('1')
729
+ with pytest.raises(ValueError):
730
+ asyncio.run(push_data_with_charges(mgr, _pages(1), {'document': 1}, True, Store().push, charge_per='page'))