scrapely-python 0.1.10__tar.gz → 0.1.11__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 (68) hide show
  1. {scrapely_python-0.1.10/src/scrapely_python.egg-info → scrapely_python-0.1.11}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/actor.py +9 -1
  5. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/charging.py +170 -3
  6. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/charging_client.py +5 -1
  7. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_dataset_client.py +6 -0
  8. {scrapely_python-0.1.10 → scrapely_python-0.1.11/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  9. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
  10. scrapely_python-0.1.11/tests/test_charge_limit_guard.py +291 -0
  11. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/LICENSE +0 -0
  12. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/LICENSE-APACHE +0 -0
  13. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/NOTICE +0 -0
  14. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/README.md +0 -0
  15. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/setup.cfg +0 -0
  16. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/_browser_locations.py +0 -0
  17. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/browser_configuration.py +0 -0
  18. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/cdp_retry.py +0 -0
  19. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/configuration.py +0 -0
  20. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/crypto/__init__.py +0 -0
  21. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/crypto/_secrets.py +0 -0
  22. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/errors.py +0 -0
  23. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/events/__init__.py +0 -0
  24. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/events/_types.py +0 -0
  25. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/events/event_manager.py +0 -0
  26. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/log.py +0 -0
  27. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/patch_browser_pool.py +0 -0
  28. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/pricing_types.py +0 -0
  29. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/proxy_configuration.py +0 -0
  30. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/py.typed +0 -0
  31. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/request_loaders/__init__.py +0 -0
  32. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  33. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/__init__.py +0 -0
  34. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/__init__.py +0 -0
  35. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  36. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  37. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/_metadata.py +0 -0
  38. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/api_client.py +0 -0
  39. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/client.py +0 -0
  40. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/patch_storage.py +0 -0
  41. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  42. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  43. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  44. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/types.py +0 -0
  46. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage/utils.py +0 -0
  47. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storage_clients/__init__.py +0 -0
  48. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/storages/__init__.py +0 -0
  49. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/types.py +0 -0
  50. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely/webhook.py +0 -0
  51. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  52. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/requires.txt +0 -0
  53. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/top_level.txt +0 -0
  54. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_api_client_retries.py +0 -0
  55. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_apify_parity.py +0 -0
  56. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_browser_configuration.py +0 -0
  57. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_cdp_retry.py +0 -0
  58. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_charge_retries.py +0 -0
  59. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_charging_parity.py +0 -0
  60. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_charging_pending.py +0 -0
  61. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_input_proxy_aliases.py +0 -0
  62. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_parity.py +0 -0
  63. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_patch_browser_pool.py +0 -0
  64. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_proxy_configuration.py +0 -0
  65. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_run_control.py +0 -0
  66. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_secrets.py +0 -0
  67. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/tests/test_storage.py +0 -0
  68. {scrapely_python-0.1.10 → scrapely_python-0.1.11}/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.10
3
+ Version: 0.1.11
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.10"
7
+ version = "0.1.11"
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.10'
135
+ __version__ = '0.1.11'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -24,7 +24,12 @@ from crawlee.configuration import Configuration as CrawleeConfiguration
24
24
  from crawlee.storage_clients._base import StorageClient
25
25
  from crawlee.storages import Dataset, KeyValueStore, RequestQueue
26
26
 
27
- from .charging import ChargingManager, is_default_dataset_item_event, push_data_and_charge
27
+ from .charging import (
28
+ ChargingManager,
29
+ is_default_dataset_item_event,
30
+ push_data_and_charge,
31
+ set_active_charging_manager,
32
+ )
28
33
  from .configuration import Configuration
29
34
  from .crypto import decrypt_secret_inputs
30
35
  from .proxy_configuration import with_proxy_key_aliases
@@ -515,6 +520,7 @@ class Actor(metaclass=_ActorMeta):
515
520
  # Clear charging + patched client so a second Actor lifecycle cannot
516
521
  # post charges to the previous run_id / credentials.
517
522
  cls._charging_manager = None
523
+ set_active_charging_manager(None)
518
524
  cls._apify_client_instance = None
519
525
 
520
526
  cls._default_dataset = None
@@ -1222,6 +1228,8 @@ class Actor(metaclass=_ActorMeta):
1222
1228
  # Prefer the storage factory client (same URL/token/run as API storage).
1223
1229
  client = cls._client if cls._client is not None else cls.new_client()
1224
1230
  cls._charging_manager = ChargingManager(config, client)
1231
+ # Storage clients refuse default-dataset writes past this run's limit.
1232
+ set_active_charging_manager(cls._charging_manager)
1225
1233
  return cls._charging_manager
1226
1234
 
1227
1235
 
@@ -9,7 +9,9 @@ never treated as retries of a previous operation.
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import contextvars
12
13
  import json
14
+ import os
13
15
  import logging
14
16
  import time
15
17
  import uuid
@@ -63,6 +65,50 @@ _RETRY_BUDGET_SECS = 60.0
63
65
  LOCAL_CHARGING_LOG_DATASET_NAME = 'charging_log'
64
66
 
65
67
 
68
+ # Charge-limit safety net for the default dataset.
69
+ #
70
+ # Once a pay-per-event run has used up its spending limit (maxTotalChargeUsd),
71
+ # rows written to the run's default dataset would go out unbilled. The SDK then
72
+ # refuses such writes: nothing is stored, one warning is logged and
73
+ # ``ChargingManager.is_charge_limit_reached()`` turns True. Pushes made by
74
+ # ``push_data_and_charge`` (``Actor.push_data``) are decided there and marked
75
+ # with this context variable, so the storage client does not check them twice.
76
+ _charge_gated_push: contextvars.ContextVar[bool] = contextvars.ContextVar(
77
+ 'scrapely_charge_gated_push', default=False
78
+ )
79
+
80
+ # The charging manager of the running Actor (set by ``Actor``); storage clients
81
+ # consult it before writing to the default dataset.
82
+ _active_charging_manager: Optional['ChargingManager'] = None
83
+
84
+
85
+ def set_active_charging_manager(manager: Optional['ChargingManager']) -> None:
86
+ """Register the charging manager whose limit guards default-dataset writes."""
87
+ global _active_charging_manager
88
+ _active_charging_manager = manager
89
+
90
+
91
+ def default_dataset_id_from_env() -> Optional[str]:
92
+ """The run's default dataset ID, as the platform passes it."""
93
+ return os.environ.get('ACTOR_DEFAULT_DATASET_ID') or os.environ.get('APIFY_DEFAULT_DATASET_ID')
94
+
95
+
96
+ def refuse_default_dataset_push(item_count: int) -> bool:
97
+ """True when a write of ``item_count`` rows to the default dataset must be dropped.
98
+
99
+ Called by storage clients for writes that did not go through
100
+ ``push_data_and_charge`` (``Actor.open_dataset().push_data()``, crawler
101
+ ``context.push_data``, ...). Non-PPE runs and runs below their limit are
102
+ never refused.
103
+ """
104
+ if _charge_gated_push.get():
105
+ return False
106
+ manager = _active_charging_manager
107
+ if manager is None:
108
+ return False
109
+ return manager._refuse_default_dataset_push(item_count)
110
+
111
+
66
112
  def is_default_dataset_item_event(event_name: str) -> bool:
67
113
  """Check if event name is a default dataset item event."""
68
114
  return event_name in (APIFY_DEFAULT_DATASET_ITEM_EVENT, SCRAPELY_DEFAULT_DATASET_ITEM_EVENT)
@@ -209,10 +255,69 @@ class ChargingManager:
209
255
  self._last_accepted_operation_id: Optional[str] = None
210
256
  # Voids not yet confirmed by the platform; retried before the next push and at exit.
211
257
  self._pending_voids: List[tuple[str, str, int, bool]] = []
258
+ # Sticky: the platform refused or capped a charge (HTTP 402 / fewer
259
+ # events accepted) or the local budget gate trimmed one.
260
+ self._charge_limit_hit = False
261
+ # Default-dataset rows refused after the limit (one warning, then a count).
262
+ self._refused_default_dataset_items = 0
212
263
 
213
264
  def close(self) -> None:
214
265
  """Mark the manager closed; subsequent charge/push operations are rejected."""
215
266
  self._closed = True
267
+ if self._refused_default_dataset_items:
268
+ logger.warning(
269
+ '%s result(s) pushed to the default dataset after the spending limit was '
270
+ 'reached were not saved',
271
+ self._refused_default_dataset_items,
272
+ )
273
+
274
+ def is_charge_limit_reached(self) -> bool:
275
+ """Whether this pay-per-event run has used up its spending limit.
276
+
277
+ True once a charge was refused or capped by the limit (the platform's
278
+ HTTP 402 or a partial acceptance, or the local budget gate), or once the
279
+ remaining budget cannot pay for even one event of the cheapest priced
280
+ type. From then on, pushes to the default dataset that are not paid
281
+ for are refused. Always False for actors without pay-per-event
282
+ pricing, without a limit, or with only free events.
283
+ """
284
+ if self._pricing_info is None:
285
+ return False
286
+ if self._charge_limit_hit:
287
+ return True
288
+ if self._max_total_charge_usd is None:
289
+ return False
290
+ prices = [price for price in self._per_event_prices.values() if price > 0]
291
+ if not prices:
292
+ return False
293
+ return self._max_total_charge_usd - self._total_charged_usd < min(prices)
294
+
295
+ def _mark_charge_limit_hit(self) -> None:
296
+ if self._pricing_info is not None:
297
+ self._charge_limit_hit = True
298
+
299
+ def _note_refused_default_dataset_items(self, count: int) -> None:
300
+ """Count rows dropped because of the limit; warn on the first one only."""
301
+ if count <= 0:
302
+ return
303
+ if not self._refused_default_dataset_items:
304
+ limit = (
305
+ f'${self._max_total_charge_usd}' if self._max_total_charge_usd is not None else 'the limit'
306
+ )
307
+ logger.warning(
308
+ 'The spending limit of this run (maxTotalChargeUsd = %s) has been reached: '
309
+ 'results pushed to the default dataset are no longer saved. The Actor should '
310
+ 'stop now (check ChargeResult.event_charge_limit_reached or '
311
+ 'Actor.get_charging_manager().is_charge_limit_reached()).',
312
+ limit,
313
+ )
314
+ self._refused_default_dataset_items += count
315
+
316
+ def _refuse_default_dataset_push(self, item_count: int) -> bool:
317
+ if item_count <= 0 or not self.is_charge_limit_reached():
318
+ return False
319
+ self._note_refused_default_dataset_items(item_count)
320
+ return True
216
321
 
217
322
  @property
218
323
  def charge_lock(self) -> RLock:
@@ -688,6 +793,8 @@ class ChargingManager:
688
793
  deadline=deadline,
689
794
  )
690
795
  except ChargeRejectedError as err:
796
+ if _is_charge_limit_rejection(err):
797
+ self._mark_charge_limit_hit()
691
798
  self._unwind_reservation(event_name, count)
692
799
  self._drop_pending(idempotency_key)
693
800
  logger.warning(
@@ -699,6 +806,7 @@ class ChargingManager:
699
806
  continue
700
807
  if accepted is not None:
701
808
  if accepted < count:
809
+ self._mark_charge_limit_hit()
702
810
  self._unwind_reservation(event_name, count - accepted)
703
811
  self._drop_pending(idempotency_key)
704
812
  confirmed += 1
@@ -784,6 +892,9 @@ class ChargingManager:
784
892
  else 0
785
893
  )
786
894
 
895
+ if chargeable_count < count:
896
+ self._mark_charge_limit_hit()
897
+
787
898
  if chargeable_count <= 0:
788
899
  return ChargeResult(
789
900
  chargeable_within_limit=self._compute_chargeable(),
@@ -835,7 +946,9 @@ class ChargingManager:
835
946
  count=chargeable_count,
836
947
  idempotency_key=idempotency_key,
837
948
  )
838
- except ChargeRejectedError:
949
+ except ChargeRejectedError as rejected:
950
+ if _is_charge_limit_rejection(rejected):
951
+ self._mark_charge_limit_hit()
839
952
  # Server definitively rejected: unwind local reservation.
840
953
  self._drop_pending(idempotency_key)
841
954
  self._unwind_reservation(event_name, chargeable_count)
@@ -849,6 +962,8 @@ class ChargingManager:
849
962
  if accepted > 0:
850
963
  self._last_accepted_operation_id = idempotency_key
851
964
  if accepted < chargeable_count:
965
+ # The platform capped the charge at the run's limit.
966
+ self._mark_charge_limit_hit()
852
967
  self._unwind_reservation(event_name, chargeable_count - accepted)
853
968
  chargeable_count = accepted
854
969
  result = ChargeResult(
@@ -1066,6 +1181,15 @@ class ChargingManager:
1066
1181
 
1067
1182
 
1068
1183
 
1184
+ def _is_charge_limit_rejection(err: ChargeRejectedError) -> bool:
1185
+ """Whether a rejected charge was refused because the run's limit is used up."""
1186
+ return (
1187
+ err.status_code == 402
1188
+ or err.reason == 'charge-limit-exceeded'
1189
+ or 'charge-limit-exceeded' in str(err).lower()
1190
+ )
1191
+
1192
+
1069
1193
  def _read_accepted_count(body: Any, fallback: int) -> int:
1070
1194
  if not isinstance(body, dict):
1071
1195
  return fallback
@@ -1166,6 +1290,24 @@ async def push_data_and_charge(
1166
1290
  if fallback_event:
1167
1291
  resolved_event = fallback_event
1168
1292
 
1293
+ # Charge-limit safety net: once the run's spending limit is used up, a
1294
+ # default-dataset push that does not pay per row (no event, or only
1295
+ # free events) stores nothing. Paid pushes go on below, where the
1296
+ # budget cap stores only the rows that are actually charged.
1297
+ if (
1298
+ is_ppe
1299
+ and is_default_dataset
1300
+ and items
1301
+ and charging_manager.is_charge_limit_reached()
1302
+ and not _push_is_paid_per_item(charging_manager, event_name, resolved_event)
1303
+ ):
1304
+ charging_manager._note_refused_default_dataset_items(len(items))
1305
+ return ChargeResult(
1306
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1307
+ charged_count=0,
1308
+ event_charge_limit_reached=True,
1309
+ )
1310
+
1169
1311
  limited_count = len(items)
1170
1312
  if is_ppe and resolved_event:
1171
1313
  # Keep the explicit event separate from the implicit default-dataset
@@ -1177,6 +1319,10 @@ async def push_data_and_charge(
1177
1319
  )
1178
1320
 
1179
1321
  limited_items = items[:limited_count] if limited_count < len(items) else items
1322
+ if is_ppe and is_default_dataset and limited_count < len(items):
1323
+ # Rows beyond what the budget can pay for are dropped.
1324
+ charging_manager._mark_charge_limit_hit()
1325
+ charging_manager._note_refused_default_dataset_items(len(items) - limited_count)
1180
1326
 
1181
1327
  if not limited_items:
1182
1328
  return ChargeResult(
@@ -1302,9 +1448,13 @@ async def push_data_and_charge(
1302
1448
  await charging_manager._cancel_uncertain_charge(name, op_id, reserved)
1303
1449
  else:
1304
1450
  await charging_manager._void_charge(name, op_id, reserved)
1451
+ if is_default_dataset and accepted_for_items < len(limited_items):
1452
+ charging_manager._note_refused_default_dataset_items(
1453
+ len(limited_items) - accepted_for_items
1454
+ )
1305
1455
  if accepted_for_items > 0:
1306
1456
  try:
1307
- await push_fn(limited_items[:accepted_for_items])
1457
+ await _gated_push(push_fn, limited_items[:accepted_for_items])
1308
1458
  except Exception as push_err:
1309
1459
  # A result is billed only if it was saved: void this
1310
1460
  # operation's platform charges and unwind local
@@ -1344,7 +1494,7 @@ async def push_data_and_charge(
1344
1494
 
1345
1495
 
1346
1496
  # No billable events — still persist the budget-limited items.
1347
- await push_fn(limited_items)
1497
+ await _gated_push(push_fn, limited_items)
1348
1498
  return ChargeResult(
1349
1499
  chargeable_within_limit=charging_manager.compute_chargeable(),
1350
1500
  charged_count=0,
@@ -1352,6 +1502,23 @@ async def push_data_and_charge(
1352
1502
  )
1353
1503
 
1354
1504
 
1505
+ def _push_is_paid_per_item(
1506
+ charging_manager: ChargingManager, event_name: Optional[str], resolved_event: Optional[str]
1507
+ ) -> bool:
1508
+ """Whether every row of this push is charged a positive price."""
1509
+ names = {name for name in (event_name, resolved_event) if name}
1510
+ return any((charging_manager._calculate_event_price(name) or Decimal('0')) > 0 for name in names)
1511
+
1512
+
1513
+ async def _gated_push(push_fn: Any, items: List[Dict[str, Any]]) -> Any:
1514
+ """Run a push already decided by push_data_and_charge (storage skips its own check)."""
1515
+ token = _charge_gated_push.set(True)
1516
+ try:
1517
+ return await push_fn(items)
1518
+ finally:
1519
+ _charge_gated_push.reset(token)
1520
+
1521
+
1355
1522
  def _http_status_of(err: BaseException) -> Optional[int]:
1356
1523
  status = getattr(err, 'status_code', None)
1357
1524
  if status is None:
@@ -43,7 +43,11 @@ class _ChargingManagerHooks:
43
43
  from .charging import has_default_dataset_item_event_in_pricing
44
44
 
45
45
  pricing_info = self._charging_manager.get_pricing_info()
46
- return has_default_dataset_item_event_in_pricing(pricing_info.per_event_prices or {})
46
+ # Past the spending limit, route default-dataset pushes through
47
+ # push_data_and_charge too, which refuses rows nobody pays for.
48
+ return has_default_dataset_item_event_in_pricing(
49
+ pricing_info.per_event_prices or {}
50
+ ) or bool(getattr(self._charging_manager, 'is_charge_limit_reached', lambda: False)())
47
51
 
48
52
  async def push_and_charge(
49
53
  self,
@@ -99,6 +99,12 @@ class ScrapelyDatasetClient(DatasetClient):
99
99
  items = [data]
100
100
  else:
101
101
  items = list(data)
102
+ # Charge-limit safety net: a pay-per-event run past its spending limit
103
+ # must not store unbilled rows in its default dataset.
104
+ from ..charging import default_dataset_id_from_env, refuse_default_dataset_push
105
+
106
+ if self._metadata.id == default_dataset_id_from_env() and refuse_default_dataset_push(len(items)):
107
+ return
102
108
  await self._api.dataset_push_items(self._metadata.id, items)
103
109
 
104
110
  @override
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.10
3
+ Version: 0.1.11
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
@@ -52,6 +52,7 @@ tests/test_api_client_retries.py
52
52
  tests/test_apify_parity.py
53
53
  tests/test_browser_configuration.py
54
54
  tests/test_cdp_retry.py
55
+ tests/test_charge_limit_guard.py
55
56
  tests/test_charge_retries.py
56
57
  tests/test_charging_parity.py
57
58
  tests/test_charging_pending.py
@@ -0,0 +1,291 @@
1
+ """Charge-limit safety net: no unbilled rows in the default dataset past maxTotalChargeUsd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from decimal import Decimal
7
+ from typing import Any, Dict, List, Optional
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+
12
+ from scrapely.charging import (
13
+ ChargingManager,
14
+ push_data_and_charge,
15
+ refuse_default_dataset_push,
16
+ set_active_charging_manager,
17
+ )
18
+ from scrapely.charging_client import _ChargingManagerHooks
19
+ from scrapely.pricing_types import PayPerEventActorPricingInfo, PricingPerEvent
20
+ from scrapely.storage._metadata import to_dataset_metadata
21
+ from scrapely.storage.scrapely_dataset_client import ScrapelyDatasetClient
22
+
23
+ DEFAULT_DATASET_ID = 'aaaaaaaa-0000-4000-8000-000000000001'
24
+ NAMED_DATASET_ID = 'bbbbbbbb-0000-4000-8000-000000000002'
25
+
26
+
27
+ class HttpError(Exception):
28
+ def __init__(self, status_code: int, message: str) -> None:
29
+ super().__init__(message)
30
+ self.status_code = status_code
31
+
32
+
33
+ class FakeChargeApi:
34
+ """Charge endpoint with a server-side budget (like the platform ledger)."""
35
+
36
+ def __init__(self, *, price: Decimal, max_usd: Optional[Decimal], reject_after: Optional[int] = None) -> None:
37
+ self.run_id = 'run-guard-1'
38
+ self.price = price
39
+ self.max_usd = max_usd
40
+ self.spent = Decimal('0')
41
+ self.calls = 0
42
+ self.reject_after = reject_after
43
+
44
+ async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, **_: Any) -> Dict[str, Any]:
45
+ count = int((json_data or {}).get('count', 1))
46
+ self.calls += 1
47
+ if self.reject_after is not None and self.calls > self.reject_after:
48
+ # Budget used up on the server (for example by platform usage).
49
+ raise HttpError(402, 'charge-limit-exceeded')
50
+ accepted = count
51
+ if self.max_usd is not None and self.price > 0:
52
+ affordable = int((self.max_usd - self.spent) / self.price)
53
+ if affordable < 1:
54
+ raise HttpError(402, 'charge-limit-exceeded')
55
+ accepted = min(count, affordable)
56
+ self.spent += self.price * accepted
57
+ return {'acceptedCount': accepted, 'requestedCount': count}
58
+
59
+
60
+ def _manager(
61
+ api: FakeChargeApi,
62
+ *,
63
+ prices: Optional[Dict[str, Decimal]] = None,
64
+ max_usd: Optional[str] = '0.03',
65
+ ppe: bool = True,
66
+ ) -> ChargingManager:
67
+ config = MagicMock()
68
+ config.get = MagicMock(
69
+ side_effect=lambda key, default=None: {
70
+ 'is_at_home': True,
71
+ 'actor_run_id': api.run_id,
72
+ 'max_total_charge_usd': max_usd,
73
+ }.get(key, default)
74
+ )
75
+ mgr = ChargingManager(config, api) # type: ignore[arg-type]
76
+ if ppe:
77
+ prices = prices if prices is not None else {'result': Decimal('0.01')}
78
+ mgr._pricing_info = PayPerEventActorPricingInfo(
79
+ pricing_per_event={name: PricingPerEvent(event_price_usd=p) for name, p in prices.items()}
80
+ )
81
+ mgr._per_event_prices = dict(prices)
82
+ mgr._initialized = True
83
+ mgr._max_total_charge_usd = Decimal(max_usd) if max_usd else None
84
+ mgr._POST_ATTEMPTS = 1
85
+ return mgr
86
+
87
+
88
+ class Store:
89
+ def __init__(self) -> None:
90
+ self.items: List[Dict[str, Any]] = []
91
+
92
+ async def push(self, items: List[Dict[str, Any]]) -> None:
93
+ self.items.extend(items)
94
+
95
+
96
+ async def _plain_push(mgr: ChargingManager, store: Store, rows: List[Dict[str, Any]]):
97
+ """Actor.push_data(rows) without an event."""
98
+ return await push_data_and_charge(mgr, rows, None, True, store.push)
99
+
100
+
101
+ async def _charged_push(mgr: ChargingManager, store: Store, rows: List[Dict[str, Any]], event: str = 'result'):
102
+ return await push_data_and_charge(mgr, rows, event, True, store.push)
103
+
104
+
105
+ @pytest.fixture(autouse=True)
106
+ def _reset_active_manager(monkeypatch: pytest.MonkeyPatch):
107
+ monkeypatch.setenv('ACTOR_DEFAULT_DATASET_ID', DEFAULT_DATASET_ID)
108
+ yield
109
+ set_active_charging_manager(None)
110
+
111
+
112
+ def _limit_warnings(caplog: pytest.LogCaptureFixture) -> List[str]:
113
+ return [r.getMessage() for r in caplog.records if 'spending limit of this run' in r.getMessage()]
114
+
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_below_limit_behaves_as_before() -> None:
118
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('1'))
119
+ mgr = _manager(api, max_usd='1')
120
+ store = Store()
121
+
122
+ r1 = await _plain_push(mgr, store, [{'n': 1}, {'n': 2}])
123
+ r2 = await _charged_push(mgr, store, [{'n': 3}])
124
+ await mgr.charge('result', 2)
125
+
126
+ assert [i['n'] for i in store.items] == [1, 2, 3]
127
+ assert r1.event_charge_limit_reached is False
128
+ assert r2.charged_count == 1 and r2.event_charge_limit_reached is False
129
+ assert mgr.is_charge_limit_reached() is False
130
+ assert refuse_default_dataset_push(5) is False
131
+
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_limit_reached_exactly_by_last_charged_row_refuses_later_free_rows(
135
+ caplog: pytest.LogCaptureFixture,
136
+ ) -> None:
137
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.03'))
138
+ mgr = _manager(api)
139
+ store = Store()
140
+
141
+ r = await _charged_push(mgr, store, [{'n': 1}, {'n': 2}, {'n': 3}])
142
+ assert r.charged_count == 3
143
+ assert [i['n'] for i in store.items] == [1, 2, 3]
144
+ assert mgr.is_charge_limit_reached() is True
145
+
146
+ # Documented edge case: a later free summary/error row is refused too.
147
+ with caplog.at_level(logging.WARNING):
148
+ summary = await _plain_push(mgr, store, [{'summary': True}])
149
+ assert summary.event_charge_limit_reached is True and summary.charged_count == 0
150
+ assert len(store.items) == 3
151
+ assert len(_limit_warnings(caplog)) == 1
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_plain_push_then_separate_charge_stops_at_the_cap(caplog: pytest.LogCaptureFixture) -> None:
156
+ """The leaking pattern: push without an event, charge separately, ignore the result."""
157
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.03'))
158
+ mgr = _manager(api)
159
+ store = Store()
160
+
161
+ with caplog.at_level(logging.WARNING):
162
+ for n in range(10):
163
+ await _plain_push(mgr, store, [{'n': n}])
164
+ await mgr.charge('result', 1)
165
+
166
+ assert [i['n'] for i in store.items] == [0, 1, 2]
167
+ assert mgr._refused_default_dataset_items == 7
168
+ assert len(_limit_warnings(caplog)) == 1
169
+
170
+
171
+ @pytest.mark.asyncio
172
+ async def test_platform_402_stops_plain_pushes() -> None:
173
+ """The server refuses a charge the local budget still allowed (e.g. usage ate the budget)."""
174
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=None, reject_after=2)
175
+ mgr = _manager(api, max_usd='5')
176
+ store = Store()
177
+
178
+ for n in range(6):
179
+ await _plain_push(mgr, store, [{'n': n}])
180
+ await mgr.charge('result', 1)
181
+
182
+ # Row 2 was pushed before its charge came back 402; nothing after that.
183
+ assert [i['n'] for i in store.items] == [0, 1, 2]
184
+ assert mgr.is_charge_limit_reached() is True
185
+
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_charged_push_after_limit_stores_nothing(caplog: pytest.LogCaptureFixture) -> None:
189
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.03'))
190
+ mgr = _manager(api)
191
+ store = Store()
192
+
193
+ first = await _charged_push(mgr, store, [{'n': n} for n in range(5)])
194
+ assert first.charged_count == 3 and first.event_charge_limit_reached is True
195
+ with caplog.at_level(logging.WARNING):
196
+ again = await _charged_push(mgr, store, [{'n': 9}])
197
+ free_event = await _charged_push(mgr, store, [{'n': 10}], event='free-event')
198
+ assert again.charged_count == 0 and again.event_charge_limit_reached is True
199
+ assert free_event.event_charge_limit_reached is True
200
+ assert [i['n'] for i in store.items] == [0, 1, 2]
201
+ assert api.spent == Decimal('0.03')
202
+ # One warning for the whole run, not one per push.
203
+ assert len(_limit_warnings(caplog)) <= 1
204
+
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_non_ppe_actor_is_unaffected() -> None:
208
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.01'))
209
+ mgr = _manager(api, ppe=False, max_usd='0.01')
210
+ set_active_charging_manager(mgr)
211
+ store = Store()
212
+
213
+ for n in range(5):
214
+ await _plain_push(mgr, store, [{'n': n}])
215
+ await mgr.charge('result', 1)
216
+
217
+ assert len(store.items) == 5
218
+ assert mgr.is_charge_limit_reached() is False
219
+ assert refuse_default_dataset_push(1) is False
220
+
221
+
222
+ @pytest.mark.asyncio
223
+ async def test_free_events_only_never_reach_the_limit() -> None:
224
+ api = FakeChargeApi(price=Decimal('0'), max_usd=Decimal('0.01'))
225
+ mgr = _manager(api, prices={'free-event': Decimal('0')}, max_usd='0.01')
226
+ store = Store()
227
+
228
+ for n in range(5):
229
+ await _charged_push(mgr, store, [{'n': n}], event='free-event')
230
+ await _plain_push(mgr, store, [{'p': n}])
231
+
232
+ assert len(store.items) == 10
233
+ assert mgr.is_charge_limit_reached() is False
234
+
235
+
236
+ def _dataset_client(dataset_id: str) -> tuple[ScrapelyDatasetClient, List[Any]]:
237
+ pushed: List[Any] = []
238
+ api = MagicMock()
239
+
240
+ async def dataset_push_items(ds_id: str, items: List[Any]) -> None:
241
+ pushed.append((ds_id, list(items)))
242
+
243
+ api.dataset_push_items = dataset_push_items
244
+ return ScrapelyDatasetClient(api=api, metadata=to_dataset_metadata({'id': dataset_id}, dataset_id)), pushed
245
+
246
+
247
+ @pytest.mark.asyncio
248
+ async def test_open_dataset_default_push_refused_named_dataset_unaffected() -> None:
249
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.01'))
250
+ mgr = _manager(api, max_usd='0.01')
251
+ set_active_charging_manager(mgr)
252
+ default_client, default_pushed = _dataset_client(DEFAULT_DATASET_ID)
253
+ named_client, named_pushed = _dataset_client(NAMED_DATASET_ID)
254
+
255
+ # Below the limit both store.
256
+ await default_client.push_data([{'n': 1}])
257
+ await named_client.push_data([{'n': 1}])
258
+ await mgr.charge('result', 1) # uses the whole budget
259
+ assert mgr.is_charge_limit_reached() is True
260
+
261
+ # Actor.open_dataset().push_data() / crawler pushes to the default dataset: refused.
262
+ await default_client.push_data([{'n': 2}, {'n': 3}])
263
+ # A named dataset keeps working.
264
+ await named_client.push_data([{'n': 2}])
265
+
266
+ assert default_pushed == [(DEFAULT_DATASET_ID, [{'n': 1}])]
267
+ assert named_pushed == [(NAMED_DATASET_ID, [{'n': 1}]), (NAMED_DATASET_ID, [{'n': 2}])]
268
+
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_paid_push_through_dataset_client_is_not_checked_twice() -> None:
272
+ """push_data_and_charge decides; its push reaches the storage client unblocked."""
273
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.02'))
274
+ mgr = _manager(api, max_usd='0.02')
275
+ set_active_charging_manager(mgr)
276
+ client, pushed = _dataset_client(DEFAULT_DATASET_ID)
277
+
278
+ r = await push_data_and_charge(mgr, [{'n': 1}, {'n': 2}], 'result', True, client.push_data)
279
+ assert r.charged_count == 2
280
+ assert mgr.is_charge_limit_reached() is True
281
+ assert pushed == [(DEFAULT_DATASET_ID, [{'n': 1}, {'n': 2}])]
282
+
283
+
284
+ @pytest.mark.asyncio
285
+ async def test_apify_client_hooks_intercept_default_dataset_past_limit() -> None:
286
+ api = FakeChargeApi(price=Decimal('0.01'), max_usd=Decimal('0.01'))
287
+ mgr = _manager(api, max_usd='0.01')
288
+ hooks = _ChargingManagerHooks(mgr)
289
+ assert hooks.should_charge_dataset_pushes(DEFAULT_DATASET_ID) is False
290
+ await mgr.charge('result', 1)
291
+ assert hooks.should_charge_dataset_pushes(DEFAULT_DATASET_ID) is True