scrapely-python 0.1.18__tar.gz → 0.1.20__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scrapely_python-0.1.18/src/scrapely_python.egg-info → scrapely_python-0.1.20}/PKG-INFO +1 -1
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/pyproject.toml +1 -1
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/charging.py +75 -9
- {scrapely_python-0.1.18 → scrapely_python-0.1.20/src/scrapely_python.egg-info}/PKG-INFO +1 -1
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charge_limit_guard.py +138 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/LICENSE +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/NOTICE +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/README.md +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/setup.cfg +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/actor.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/solver.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/api_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/top_level.txt +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_api_client_retries.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_apify_parity.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_browser_configuration.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_cdp_retry.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charge_retries.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charging_parity.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_charging_pending.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_input_proxy_aliases.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_parity.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_patch_browser_pool.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_proxy_configuration.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_push_idempotency.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_run_control.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_secrets.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_solver.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/tests/test_storage.py +0 -0
- {scrapely_python-0.1.18 → scrapely_python-0.1.20}/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.20"
|
|
8
8
|
description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT AND Apache-2.0"
|
|
@@ -240,6 +240,14 @@ class ChargingManager:
|
|
|
240
240
|
self._charged_events: Dict[str, int] = {}
|
|
241
241
|
self._max_total_charge_usd: Optional[Decimal] = None
|
|
242
242
|
self._total_charged_usd: Decimal = Decimal('0.0')
|
|
243
|
+
# Run spend the platform counts against maxTotalChargeUsd that this SDK
|
|
244
|
+
# does not charge itself: compute/proxy/browser usage and server-billed
|
|
245
|
+
# events. Learnt from the charge endpoint's remainingBudgetNd (only ever
|
|
246
|
+
# grows), so the local budget gates on the run's true remaining budget.
|
|
247
|
+
self._platform_other_usd: Decimal = Decimal('0')
|
|
248
|
+
self._last_remaining_budget_nd: Optional[int] = None
|
|
249
|
+
# True once the platform reported a remaining budget (its usage is then in the local budget).
|
|
250
|
+
self._platform_budget_seen = False
|
|
243
251
|
self._charge_lock = RLock()
|
|
244
252
|
self._flush_lock = asyncio.Lock()
|
|
245
253
|
# Serializes budget calculation/reservation with PPE-aware dataset pushes.
|
|
@@ -288,6 +296,28 @@ class ChargingManager:
|
|
|
288
296
|
self._refused_default_dataset_items,
|
|
289
297
|
)
|
|
290
298
|
|
|
299
|
+
def _remaining_budget_usd(self) -> Decimal:
|
|
300
|
+
"""maxTotalChargeUsd minus this SDK's charges and the platform's other run spend."""
|
|
301
|
+
assert self._max_total_charge_usd is not None
|
|
302
|
+
return self._max_total_charge_usd - self._total_charged_usd - self._platform_other_usd
|
|
303
|
+
|
|
304
|
+
def _sync_platform_budget(self) -> None:
|
|
305
|
+
"""Fold the last charge response's remainingBudgetNd into the local budget.
|
|
306
|
+
|
|
307
|
+
Called after the local reservation matches what the platform accepted.
|
|
308
|
+
The platform's remaining budget = max - (our charges + usage + server
|
|
309
|
+
events), so ``other`` = max - remaining - our charges. Monotonic: usage
|
|
310
|
+
never shrinks, and a stale answer must not hand budget back.
|
|
311
|
+
"""
|
|
312
|
+
nd = self._last_remaining_budget_nd
|
|
313
|
+
self._last_remaining_budget_nd = None
|
|
314
|
+
if nd is None or self._max_total_charge_usd is None or nd < 0:
|
|
315
|
+
return
|
|
316
|
+
self._platform_budget_seen = True
|
|
317
|
+
other = self._max_total_charge_usd - Decimal(nd) / Decimal(1_000_000_000) - self._total_charged_usd
|
|
318
|
+
if other > self._platform_other_usd:
|
|
319
|
+
self._platform_other_usd = other
|
|
320
|
+
|
|
291
321
|
def is_charge_limit_reached(self) -> bool:
|
|
292
322
|
"""Whether this pay-per-event run has used up its spending limit.
|
|
293
323
|
|
|
@@ -307,7 +337,7 @@ class ChargingManager:
|
|
|
307
337
|
prices = [price for price in self._per_event_prices.values() if price > 0]
|
|
308
338
|
if not prices:
|
|
309
339
|
return False
|
|
310
|
-
return self.
|
|
340
|
+
return self._remaining_budget_usd() < min(prices)
|
|
311
341
|
|
|
312
342
|
def _mark_charge_limit_hit(self) -> None:
|
|
313
343
|
if self._pricing_info is not None:
|
|
@@ -795,6 +825,7 @@ class ChargingManager:
|
|
|
795
825
|
idempotency_key=idempotency_key,
|
|
796
826
|
deadline=deadline,
|
|
797
827
|
)
|
|
828
|
+
self._last_remaining_budget_nd = _read_remaining_budget_nd(body)
|
|
798
829
|
return _read_accepted_count(body, count)
|
|
799
830
|
except Exception as e:
|
|
800
831
|
last_error = e
|
|
@@ -957,7 +988,7 @@ class ChargingManager:
|
|
|
957
988
|
# legacy one-event overdraw while the budget is not yet exceeded.
|
|
958
989
|
chargeable_count = count
|
|
959
990
|
if self._max_total_charge_usd is not None and affordability_price > 0:
|
|
960
|
-
remaining = self.
|
|
991
|
+
remaining = self._remaining_budget_usd()
|
|
961
992
|
max_count_by_limit = max(0, int(remaining / affordability_price))
|
|
962
993
|
if count > max_count_by_limit:
|
|
963
994
|
chargeable_count = (
|
|
@@ -1045,6 +1076,15 @@ class ChargingManager:
|
|
|
1045
1076
|
charged_count=chargeable_count,
|
|
1046
1077
|
event_charge_limit_reached=self._is_event_limit_reached(event_name),
|
|
1047
1078
|
)
|
|
1079
|
+
self._sync_platform_budget()
|
|
1080
|
+
if self._max_total_charge_usd is not None and self._platform_other_usd > 0:
|
|
1081
|
+
# Usage may have used up what is left: report it on this result.
|
|
1082
|
+
result = ChargeResult(
|
|
1083
|
+
chargeable_within_limit=self._compute_chargeable(),
|
|
1084
|
+
charged_count=result.charged_count,
|
|
1085
|
+
event_charge_limit_reached=result.event_charge_limit_reached
|
|
1086
|
+
or self._is_event_limit_reached(event_name),
|
|
1087
|
+
)
|
|
1048
1088
|
else:
|
|
1049
1089
|
raise ChargeUncertainError(
|
|
1050
1090
|
f'Charge for event {event_name!r} (count={chargeable_count}) '
|
|
@@ -1103,7 +1143,7 @@ class ChargingManager:
|
|
|
1103
1143
|
if event_price is None or event_price <= 0:
|
|
1104
1144
|
return None
|
|
1105
1145
|
|
|
1106
|
-
remaining = self.
|
|
1146
|
+
remaining = self._remaining_budget_usd()
|
|
1107
1147
|
return max(0, int(remaining / event_price))
|
|
1108
1148
|
|
|
1109
1149
|
def calculate_total_charged_amount(self) -> Decimal:
|
|
@@ -1165,7 +1205,7 @@ class ChargingManager:
|
|
|
1165
1205
|
if self._max_total_charge_usd is None:
|
|
1166
1206
|
return items_count
|
|
1167
1207
|
|
|
1168
|
-
remaining = self.
|
|
1208
|
+
remaining = self._remaining_budget_usd()
|
|
1169
1209
|
max_items = int(remaining / combined_price)
|
|
1170
1210
|
|
|
1171
1211
|
return min(items_count, max(0, max_items))
|
|
@@ -1226,7 +1266,7 @@ class ChargingManager:
|
|
|
1226
1266
|
if self._max_total_charge_usd is None:
|
|
1227
1267
|
return False
|
|
1228
1268
|
|
|
1229
|
-
remaining = self.
|
|
1269
|
+
remaining = self._remaining_budget_usd()
|
|
1230
1270
|
return remaining < event_price
|
|
1231
1271
|
|
|
1232
1272
|
def _compute_chargeable(self) -> Dict[str, Optional[int]]:
|
|
@@ -1264,6 +1304,17 @@ def _is_charge_limit_rejection(err: ChargeRejectedError) -> bool:
|
|
|
1264
1304
|
)
|
|
1265
1305
|
|
|
1266
1306
|
|
|
1307
|
+
def _read_remaining_budget_nd(body: Any) -> Optional[int]:
|
|
1308
|
+
"""remainingBudgetNd from a charge response (platforms that send it), else None."""
|
|
1309
|
+
if not isinstance(body, dict):
|
|
1310
|
+
return None
|
|
1311
|
+
data = body.get('data') if isinstance(body.get('data'), dict) else body
|
|
1312
|
+
raw = data.get('remainingBudgetNd') if isinstance(data, dict) else None
|
|
1313
|
+
if isinstance(raw, (int, float)) and not isinstance(raw, bool) and raw >= 0:
|
|
1314
|
+
return int(raw)
|
|
1315
|
+
return None
|
|
1316
|
+
|
|
1317
|
+
|
|
1267
1318
|
def _read_accepted_count(body: Any, fallback: int) -> int:
|
|
1268
1319
|
if not isinstance(body, dict):
|
|
1269
1320
|
return fallback
|
|
@@ -1653,7 +1704,7 @@ async def push_data_with_charges(
|
|
|
1653
1704
|
)
|
|
1654
1705
|
rows = len(rows_in)
|
|
1655
1706
|
if charging_manager._max_total_charge_usd is not None and row_cost > 0:
|
|
1656
|
-
remaining = charging_manager.
|
|
1707
|
+
remaining = charging_manager._remaining_budget_usd()
|
|
1657
1708
|
rows = min(rows, max(0, int(remaining / row_cost)))
|
|
1658
1709
|
|
|
1659
1710
|
ops: List[tuple] = [] # (event, charged, op_id, uncertain)
|
|
@@ -1669,7 +1720,7 @@ async def push_data_with_charges(
|
|
|
1669
1720
|
else:
|
|
1670
1721
|
await charging_manager._void_charge(name, op_id, charged)
|
|
1671
1722
|
|
|
1672
|
-
for _attempt in range(
|
|
1723
|
+
for _attempt in range(6):
|
|
1673
1724
|
if rows <= 0:
|
|
1674
1725
|
break
|
|
1675
1726
|
ops = []
|
|
@@ -1693,10 +1744,25 @@ async def push_data_with_charges(
|
|
|
1693
1744
|
if short_to is None:
|
|
1694
1745
|
break
|
|
1695
1746
|
# Some event came back short: void this attempt in full and retry with
|
|
1696
|
-
# the rows
|
|
1747
|
+
# the whole rows the budget covers at the FULL per-row cost. Sizing
|
|
1748
|
+
# from the short event alone ignores the other events of the row, so
|
|
1749
|
+
# the retry came back short again (appstore 64df0a3608d74af88: 20
|
|
1750
|
+
# details -> 14, retry sized 14, app-item short, 0 saved). What this
|
|
1751
|
+
# attempt was accepted is budget the void gives back; with the
|
|
1752
|
+
# platform's remainingBudgetNd the local budget also knows the rest.
|
|
1753
|
+
attempt_spent = sum(
|
|
1754
|
+
(charging_manager._calculate_event_price(name) or Decimal('0')) * Decimal(charged)
|
|
1755
|
+
for name, charged, _op, _unc in ops
|
|
1756
|
+
)
|
|
1697
1757
|
await undo(ops)
|
|
1698
1758
|
ops = []
|
|
1699
|
-
|
|
1759
|
+
fit = int(attempt_spent / row_cost) if row_cost > 0 else 0
|
|
1760
|
+
if charging_manager._platform_budget_seen and charging_manager._max_total_charge_usd is not None and row_cost > 0:
|
|
1761
|
+
fit = max(fit, int(charging_manager._remaining_budget_usd() / row_cost))
|
|
1762
|
+
# At least one more single-row try: without the platform's remaining
|
|
1763
|
+
# budget the leftover after the short answer is unknown, and the last
|
|
1764
|
+
# affordable row may still fit.
|
|
1765
|
+
rows = min(rows - 1, max(fit, 1))
|
|
1700
1766
|
|
|
1701
1767
|
if rows <= 0 or not ops:
|
|
1702
1768
|
await undo(ops)
|
|
@@ -509,3 +509,141 @@ def test_multi_event_validation() -> None:
|
|
|
509
509
|
for bad in ({}, {'result': 0}, {'result': 1.5}, {'apify-default-dataset-item': 1}, {'': 1}, None):
|
|
510
510
|
with pytest.raises(ValueError):
|
|
511
511
|
_validate_row_charges(bad)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---- platform usage counts against the cap (remainingBudgetNd in charge responses) ----
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
class UsageApi(FakeChargeApi):
|
|
518
|
+
"""Ledger whose cap also covers platform usage, reporting remainingBudgetNd like the server."""
|
|
519
|
+
|
|
520
|
+
def __init__(self, *, price: Decimal, max_usd: Decimal, usage: Decimal, send_remaining: bool = True) -> None:
|
|
521
|
+
super().__init__(price=price, max_usd=max_usd)
|
|
522
|
+
self.usage = usage
|
|
523
|
+
self.send_remaining = send_remaining
|
|
524
|
+
|
|
525
|
+
async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, **_: Any) -> Dict[str, Any]:
|
|
526
|
+
count = int((json_data or {}).get('count', 1))
|
|
527
|
+
left = self.max_usd - self.spent - self.usage
|
|
528
|
+
affordable = int(left / self.price)
|
|
529
|
+
if affordable < 1:
|
|
530
|
+
raise HttpError(402, 'charge-limit-exceeded')
|
|
531
|
+
accepted = min(count, affordable)
|
|
532
|
+
self.spent += self.price * accepted
|
|
533
|
+
body: Dict[str, Any] = {'acceptedCount': accepted, 'requestedCount': count}
|
|
534
|
+
if self.send_remaining:
|
|
535
|
+
body['remainingBudgetNd'] = int((self.max_usd - self.spent - self.usage) * 1_000_000_000)
|
|
536
|
+
return body
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@pytest.mark.asyncio
|
|
540
|
+
async def test_platform_usage_shrinks_the_local_budget() -> None:
|
|
541
|
+
api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('0.05'), usage=Decimal('0.03'))
|
|
542
|
+
mgr = _manager(api, max_usd='0.05')
|
|
543
|
+
store = Store()
|
|
544
|
+
|
|
545
|
+
r = await mgr.charge('result', 1)
|
|
546
|
+
assert r.charged_count == 1
|
|
547
|
+
# $0.05 cap - $0.01 charged - $0.03 usage: one more event fits, not four.
|
|
548
|
+
assert mgr.calculate_max_event_charge_count_within_limit('result') == 1
|
|
549
|
+
assert mgr.is_charge_limit_reached() is False
|
|
550
|
+
pushed = await _charged_push(mgr, store, [{'n': n} for n in range(5)])
|
|
551
|
+
assert pushed.charged_count == 1 and pushed.event_charge_limit_reached is True
|
|
552
|
+
assert len(store.items) == 1
|
|
553
|
+
assert mgr.is_charge_limit_reached() is True
|
|
554
|
+
# The prepaid row from the first charge is still saved; nothing beyond it.
|
|
555
|
+
await _plain_push(mgr, store, [{'p': 1}, {'p': 2}])
|
|
556
|
+
assert len(store.items) == 2
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@pytest.mark.asyncio
|
|
560
|
+
async def test_usage_learnt_from_a_charge_marks_the_limit_on_that_result() -> None:
|
|
561
|
+
api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('0.05'), usage=Decimal('0.04'))
|
|
562
|
+
mgr = _manager(api, max_usd='0.05')
|
|
563
|
+
r = await mgr.charge('result', 1)
|
|
564
|
+
assert r.charged_count == 1
|
|
565
|
+
assert r.event_charge_limit_reached is True
|
|
566
|
+
assert mgr.is_charge_limit_reached() is True
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@pytest.mark.asyncio
|
|
570
|
+
async def test_without_remaining_budget_in_responses_behaviour_is_unchanged() -> None:
|
|
571
|
+
api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('0.05'), usage=Decimal('0.03'), send_remaining=False)
|
|
572
|
+
mgr = _manager(api, max_usd='0.05')
|
|
573
|
+
await mgr.charge('result', 1)
|
|
574
|
+
assert mgr.calculate_max_event_charge_count_within_limit('result') == 4
|
|
575
|
+
assert mgr._platform_other_usd == 0
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@pytest.mark.asyncio
|
|
579
|
+
async def test_platform_other_spend_never_shrinks() -> None:
|
|
580
|
+
api = UsageApi(price=Decimal('0.01'), max_usd=Decimal('1'), usage=Decimal('0.30'))
|
|
581
|
+
mgr = _manager(api, max_usd='1')
|
|
582
|
+
await mgr.charge('result', 1)
|
|
583
|
+
seen = mgr._platform_other_usd
|
|
584
|
+
assert seen == Decimal('0.30')
|
|
585
|
+
api.usage = Decimal('0.10') # a stale or lower report
|
|
586
|
+
await mgr.charge('result', 1)
|
|
587
|
+
assert mgr._platform_other_usd == seen
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ---- multi-event retry sizing: whole rows at the FULL per-row cost ----
|
|
592
|
+
|
|
593
|
+
APP_PRICES = {'app-details': Decimal('0.00015'), 'app-item': Decimal('0.00005')} # row = $0.0002
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class RemainingMultiApi(MultiApi):
|
|
597
|
+
"""MultiApi that also reports remainingBudgetNd (server #725)."""
|
|
598
|
+
|
|
599
|
+
async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kw: Any) -> Dict[str, Any]:
|
|
600
|
+
body = await super().post(path, json_data=json_data, headers=headers, **kw)
|
|
601
|
+
if not path.endswith('/charge/void') and self.max_usd is not None:
|
|
602
|
+
body = {**body, 'remainingBudgetNd': int((self.max_usd - self.spent) * 1_000_000_000)}
|
|
603
|
+
return body
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _app(server_max: str, remaining: bool = False):
|
|
607
|
+
api = (RemainingMultiApi if remaining else MultiApi)(APP_PRICES, Decimal(server_max))
|
|
608
|
+
return api, _manager(api, prices=dict(APP_PRICES), max_usd='1')
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@pytest.mark.asyncio
|
|
612
|
+
async def test_retry_after_one_short_event_is_sized_by_the_full_row_cost() -> None:
|
|
613
|
+
"""appstore 64df0a3608d74af88: details 20 -> 14 accepted; the retry must not size from 14."""
|
|
614
|
+
api, mgr = _app('0.0021')
|
|
615
|
+
store = Store()
|
|
616
|
+
r = await push_data_with_charges(mgr, [{'n': n} for n in range(20)], {'app-details': 1, 'app-item': 1}, True, store.push)
|
|
617
|
+
assert r.charged_count == 10 # $0.0021 / $0.0002 per full row
|
|
618
|
+
assert len(store.items) == 10
|
|
619
|
+
assert api.spent == Decimal('0.0020')
|
|
620
|
+
assert sorted(api.ops.values()) == [('app-details', 10), ('app-item', 10)]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@pytest.mark.asyncio
|
|
624
|
+
async def test_retry_when_several_events_come_back_short() -> None:
|
|
625
|
+
"""Both events of the row run short on the way down; it still converges on whole paid rows."""
|
|
626
|
+
api, mgr = _multi('1', server_max='0.0405') # 2.53 rows of $0.016 (1 result + 3 pages)
|
|
627
|
+
store = Store()
|
|
628
|
+
r = await push_data_with_charges(mgr, [{'n': n} for n in range(6)], {'result': 1, 'page': 3}, True, store.push)
|
|
629
|
+
assert r.charged_count == 2
|
|
630
|
+
assert len(store.items) == 2
|
|
631
|
+
assert api.spent == Decimal('0.032')
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@pytest.mark.asyncio
|
|
635
|
+
async def test_last_affordable_row_is_saved_without_remaining_budget() -> None:
|
|
636
|
+
api, mgr = _app('0.0002') # exactly one row; the short answer hides the leftover
|
|
637
|
+
store = Store()
|
|
638
|
+
r = await push_data_with_charges(mgr, [{'n': n} for n in range(5)], {'app-details': 1, 'app-item': 1}, True, store.push)
|
|
639
|
+
assert r.charged_count == 1 and len(store.items) == 1
|
|
640
|
+
assert api.spent == Decimal('0.0002')
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@pytest.mark.asyncio
|
|
644
|
+
async def test_platform_remaining_budget_sizes_the_retry_exactly() -> None:
|
|
645
|
+
api, mgr = _app('0.00215', remaining=True) # 10.75 rows
|
|
646
|
+
store = Store()
|
|
647
|
+
r = await push_data_with_charges(mgr, [{'n': n} for n in range(20)], {'app-details': 1, 'app-item': 1}, True, store.push)
|
|
648
|
+
assert r.charged_count == 10 and len(store.items) == 10
|
|
649
|
+
assert api.spent == Decimal('0.0020')
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.18 → scrapely_python-0.1.20}/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.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_dataset_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_kvs_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely/storage/scrapely_rq_client.py
RENAMED
|
File without changes
|
{scrapely_python-0.1.18 → scrapely_python-0.1.20}/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
|
|
File without changes
|
{scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.18 → scrapely_python-0.1.20}/src/scrapely_python.egg-info/top_level.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
|
|
File without changes
|
|
File without changes
|