scrapely-python 0.1.9__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.
- {scrapely_python-0.1.9/src/scrapely_python.egg-info → scrapely_python-0.1.11}/PKG-INFO +1 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/pyproject.toml +1 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/actor.py +9 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/charging.py +173 -4
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/charging_client.py +5 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_dataset_client.py +6 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/SOURCES.txt +1 -0
- scrapely_python-0.1.11/tests/test_charge_limit_guard.py +291 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_charging_parity.py +38 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_charging_pending.py +2 -1
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/LICENSE +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/NOTICE +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/README.md +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/setup.cfg +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/api_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_api_client_retries.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_cdp_retry.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_charge_retries.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_input_proxy_aliases.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_storage.py +0 -0
- {scrapely_python-0.1.9 → scrapely_python-0.1.11}/tests/test_terminal_status_message.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scrapely-python"
|
|
7
|
-
version = "0.1.
|
|
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"
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -1333,7 +1483,9 @@ async def push_data_and_charge(
|
|
|
1333
1483
|
raise
|
|
1334
1484
|
return ChargeResult(
|
|
1335
1485
|
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1336
|
-
|
|
1486
|
+
# Rows charged, as Apify reports it for the pushed event; the
|
|
1487
|
+
# implicit default-dataset event is not counted on top.
|
|
1488
|
+
charged_count=accepted_for_items,
|
|
1337
1489
|
event_charge_limit_reached=any(
|
|
1338
1490
|
result.event_charge_limit_reached for result in results
|
|
1339
1491
|
)
|
|
@@ -1342,7 +1494,7 @@ async def push_data_and_charge(
|
|
|
1342
1494
|
|
|
1343
1495
|
|
|
1344
1496
|
# No billable events — still persist the budget-limited items.
|
|
1345
|
-
await push_fn
|
|
1497
|
+
await _gated_push(push_fn, limited_items)
|
|
1346
1498
|
return ChargeResult(
|
|
1347
1499
|
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1348
1500
|
charged_count=0,
|
|
@@ -1350,6 +1502,23 @@ async def push_data_and_charge(
|
|
|
1350
1502
|
)
|
|
1351
1503
|
|
|
1352
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
|
+
|
|
1353
1522
|
def _http_status_of(err: BaseException) -> Optional[int]:
|
|
1354
1523
|
status = getattr(err, 'status_code', None)
|
|
1355
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
|
-
|
|
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,
|
{scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
@@ -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
|
|
@@ -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
|
|
@@ -338,3 +338,41 @@ async def test_local_mode_never_posts_charges_despite_client_and_run_id() -> Non
|
|
|
338
338
|
# Flushing must not attempt any remote POST either.
|
|
339
339
|
assert await mgr.flush_pending() == 0
|
|
340
340
|
assert client.post_calls == []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@pytest.mark.asyncio
|
|
344
|
+
async def test_push_data_with_explicit_event_reports_rows_not_rows_times_events() -> None:
|
|
345
|
+
"""charged_count is the number of rows charged, not rows x events.
|
|
346
|
+
|
|
347
|
+
With an explicit event on the default dataset the SDK also charges the
|
|
348
|
+
(unpriced) default-dataset-item event. Apify returns the explicit event's
|
|
349
|
+
count; summing both reported twice the rows pushed.
|
|
350
|
+
"""
|
|
351
|
+
from scrapely.charging import push_data_and_charge
|
|
352
|
+
|
|
353
|
+
pricing = '{"pricingModel": "PAY_PER_EVENT", "pricingPerEvent": {"actorChargeEvents": {"profile": {"eventPriceUsd": 0.001, "eventTitle": "Profile"}}}}'
|
|
354
|
+
config = _config_with({
|
|
355
|
+
'is_at_home': False,
|
|
356
|
+
'actor_pricing_info': pricing,
|
|
357
|
+
'charged_event_counts': '{}',
|
|
358
|
+
'max_total_charge_usd': '10',
|
|
359
|
+
})
|
|
360
|
+
mgr = ChargingManager(config, None)
|
|
361
|
+
await mgr.init()
|
|
362
|
+
|
|
363
|
+
pushed: List[Any] = []
|
|
364
|
+
|
|
365
|
+
async def push_fn(items: List[Any]) -> None:
|
|
366
|
+
pushed.extend(items)
|
|
367
|
+
|
|
368
|
+
rows = [{'i': i} for i in range(3)]
|
|
369
|
+
result = await push_data_and_charge(mgr, rows, 'profile', True, push_fn)
|
|
370
|
+
|
|
371
|
+
assert len(pushed) == 3
|
|
372
|
+
assert result.charged_count == 3
|
|
373
|
+
assert result.event_charge_limit_reached is False
|
|
374
|
+
assert mgr.get_charged_event_count('profile') == 3
|
|
375
|
+
|
|
376
|
+
# Without an explicit event the default event alone is counted once.
|
|
377
|
+
result2 = await push_data_and_charge(mgr, rows, None, True, push_fn)
|
|
378
|
+
assert result2.charged_count == 3
|
|
@@ -286,7 +286,8 @@ async def test_explicit_and_default_dataset_events_are_each_reserved_once() -> N
|
|
|
286
286
|
)
|
|
287
287
|
|
|
288
288
|
assert len(pushed) == 2
|
|
289
|
-
|
|
289
|
+
# Rows charged (Apify parity), not rows x events: both events billed 2 each.
|
|
290
|
+
assert result.charged_count == 2
|
|
290
291
|
assert mgr.get_charged_event_count('page-open') == 2
|
|
291
292
|
assert mgr.get_charged_event_count('apify-default-dataset-item') == 2
|
|
292
293
|
assert mgr.get_total_charged_usd() == Decimal('0.04')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/_file_system/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_kvs_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely/storage/scrapely_storage_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.9 → scrapely_python-0.1.11}/src/scrapely_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|