scrapely-python 0.1.0__py3-none-any.whl

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 (43) hide show
  1. scrapely/__init__.py +246 -0
  2. scrapely/_browser_locations.py +5470 -0
  3. scrapely/actor.py +1836 -0
  4. scrapely/browser_configuration.py +146 -0
  5. scrapely/charging.py +1143 -0
  6. scrapely/charging_client.py +107 -0
  7. scrapely/configuration.py +375 -0
  8. scrapely/crypto/__init__.py +21 -0
  9. scrapely/crypto/_secrets.py +210 -0
  10. scrapely/errors.py +153 -0
  11. scrapely/events/__init__.py +57 -0
  12. scrapely/events/_types.py +194 -0
  13. scrapely/events/event_manager.py +292 -0
  14. scrapely/log.py +39 -0
  15. scrapely/patch_browser_pool.py +861 -0
  16. scrapely/pricing_types.py +172 -0
  17. scrapely/proxy_configuration.py +741 -0
  18. scrapely/py.typed +2 -0
  19. scrapely/request_loaders/__init__.py +5 -0
  20. scrapely/request_loaders/_scrapely_request_list.py +169 -0
  21. scrapely/storage/__init__.py +48 -0
  22. scrapely/storage/_file_system/__init__.py +9 -0
  23. scrapely/storage/_file_system/_key_value_store_client.py +155 -0
  24. scrapely/storage/_file_system/_storage_client.py +50 -0
  25. scrapely/storage/_metadata.py +138 -0
  26. scrapely/storage/api_client.py +350 -0
  27. scrapely/storage/client.py +618 -0
  28. scrapely/storage/patch_storage.py +257 -0
  29. scrapely/storage/scrapely_dataset_client.py +305 -0
  30. scrapely/storage/scrapely_kvs_client.py +220 -0
  31. scrapely/storage/scrapely_rq_client.py +496 -0
  32. scrapely/storage/scrapely_storage_client.py +192 -0
  33. scrapely/storage/smart_scrapely_storage_client.py +109 -0
  34. scrapely/storage/types.py +601 -0
  35. scrapely/storage/utils.py +38 -0
  36. scrapely/storage_clients/__init__.py +25 -0
  37. scrapely/storages/__init__.py +5 -0
  38. scrapely/types.py +269 -0
  39. scrapely/webhook.py +82 -0
  40. scrapely_python-0.1.0.dist-info/METADATA +83 -0
  41. scrapely_python-0.1.0.dist-info/RECORD +43 -0
  42. scrapely_python-0.1.0.dist-info/WHEEL +5 -0
  43. scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
scrapely/charging.py ADDED
@@ -0,0 +1,1143 @@
1
+ """
2
+ Charging manager for pay-per-event pricing.
3
+
4
+ Each charge() call is one logical operation with a unique Idempotency-Key.
5
+ Failed POSTs keep a pending intent for flush/retry; later charge() calls are
6
+ never treated as retries of a previous operation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import time
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from decimal import Decimal
18
+ from threading import RLock
19
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
20
+
21
+ from .pricing_types import (
22
+ ActorPricingInfoResult,
23
+ ChargeResult,
24
+ PayPerEventActorPricingInfo,
25
+ PricingModel,
26
+ PricingPerEvent,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from .configuration import Configuration
31
+ from .storage.api_client import ApiClient
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Apify-compatible synthetic event for default-dataset items in pay-per-event actors.
36
+ APIFY_DEFAULT_DATASET_ITEM_EVENT = 'apify-default-dataset-item'
37
+
38
+ # Scrapely-native synthetic event for default-dataset items.
39
+ SCRAPELY_DEFAULT_DATASET_ITEM_EVENT = 'scrapely-default-dataset-item'
40
+
41
+ # Apify-compatible alias — imported actors expect this constant name.
42
+ DEFAULT_DATASET_ITEM_EVENT = APIFY_DEFAULT_DATASET_ITEM_EVENT
43
+
44
+ # Must stay below server CHARGE_IDEMPOTENCY_TTL (35d). Do not replay past this age.
45
+ CHARGE_IDEMPOTENCY_SAFE_AGE_SECS = 30 * 24 * 60 * 60
46
+
47
+ # Soft cap on open unresolved intents. Refuse NEW billable charges when full.
48
+ _PENDING_MAX_ENTRIES = 1024
49
+
50
+ # Immediate same-key POST attempts before leaving intent pending / raising.
51
+ _POST_ATTEMPTS = 3
52
+
53
+ # Local-only dataset collecting one entry per logical charge operation
54
+ # (JS ChargingManager LOCAL_CHARGING_LOG_DATASET_NAME parity).
55
+ LOCAL_CHARGING_LOG_DATASET_NAME = 'charging_log'
56
+
57
+
58
+ def is_default_dataset_item_event(event_name: str) -> bool:
59
+ """Check if event name is a default dataset item event."""
60
+ return event_name in (APIFY_DEFAULT_DATASET_ITEM_EVENT, SCRAPELY_DEFAULT_DATASET_ITEM_EVENT)
61
+
62
+
63
+ def resolve_default_dataset_item_event_name(pricing_info: dict) -> str:
64
+ """Resolve the default dataset item event name from pricing info."""
65
+ if APIFY_DEFAULT_DATASET_ITEM_EVENT in pricing_info:
66
+ return APIFY_DEFAULT_DATASET_ITEM_EVENT
67
+ if SCRAPELY_DEFAULT_DATASET_ITEM_EVENT in pricing_info:
68
+ return SCRAPELY_DEFAULT_DATASET_ITEM_EVENT
69
+ return APIFY_DEFAULT_DATASET_ITEM_EVENT
70
+
71
+
72
+ def has_default_dataset_item_event_in_pricing(per_event_prices: dict) -> bool:
73
+ """Check if default dataset item event pricing is configured."""
74
+ return (
75
+ APIFY_DEFAULT_DATASET_ITEM_EVENT in per_event_prices or
76
+ SCRAPELY_DEFAULT_DATASET_ITEM_EVENT in per_event_prices
77
+ )
78
+
79
+
80
+ # Backwards-compatible alias for the pre-rename camelCase name.
81
+ # Deprecated — use ``has_default_dataset_item_event_in_pricing`` instead.
82
+ hasDefaultDatasetItemEventInPricing = has_default_dataset_item_event_in_pricing
83
+
84
+
85
+ def merge_charge_results(a: ChargeResult, b: ChargeResult) -> ChargeResult:
86
+ """Merge two :class:`ChargeResult` objects (Node SDK ``mergeChargeResults`` parity).
87
+
88
+ Sums charged counts, ORs the limit-reached flag, and keeps the most
89
+ restrictive remaining-limit per event type.
90
+ """
91
+ def _min_optional(x: Optional[int], y: Optional[int]) -> Optional[int]:
92
+ # None means "no limit known" — the least restrictive value.
93
+ if x is None:
94
+ return y
95
+ if y is None:
96
+ return x
97
+ return min(x, y)
98
+
99
+ merged_limits: Dict[str, Optional[int]] = {
100
+ key: _min_optional(value, b.chargeable_within_limit.get(key, value))
101
+ for key, value in a.chargeable_within_limit.items()
102
+ }
103
+ return ChargeResult(
104
+ event_charge_limit_reached=a.event_charge_limit_reached or b.event_charge_limit_reached,
105
+ charged_count=a.charged_count + b.charged_count,
106
+ chargeable_within_limit=merged_limits,
107
+ )
108
+
109
+
110
+ class ChargeUncertainError(Exception):
111
+ """Raised when a charge POST could not be confirmed after retries.
112
+
113
+ Local budget still reserves the count for this operation. The intent remains
114
+ pending for later flush with the same Idempotency-Key. Do not assume the
115
+ platform did or did not apply the charge.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ message: str,
121
+ *,
122
+ operation_id: str,
123
+ event_name: str,
124
+ count: int,
125
+ ) -> None:
126
+ super().__init__(message)
127
+ self.operation_id = operation_id
128
+ self.event_name = event_name
129
+ self.count = count
130
+
131
+
132
+ class ChargeRejectedError(Exception):
133
+ """Definitive platform rejection (402/400/409). Do not retry with the same key."""
134
+
135
+ def __init__(
136
+ self,
137
+ message: str,
138
+ *,
139
+ operation_id: str,
140
+ event_name: str,
141
+ count: int,
142
+ status_code: Optional[int] = None,
143
+ reason: str = 'rejected',
144
+ ) -> None:
145
+ super().__init__(message)
146
+ self.operation_id = operation_id
147
+ self.event_name = event_name
148
+ self.count = count
149
+ self.status_code = status_code
150
+ self.reason = reason
151
+
152
+
153
+ class ChargingManager:
154
+ """Manages charging for pay-per-event Actors.
155
+
156
+ Tracks event counts and enforces spending limits.
157
+ Provides fine-grained access to pay-per-event functionality.
158
+ """
159
+
160
+ _PENDING_MAX_ENTRIES = _PENDING_MAX_ENTRIES
161
+ _IDEMPOTENCY_SAFE_AGE_SECS = CHARGE_IDEMPOTENCY_SAFE_AGE_SECS
162
+ _POST_ATTEMPTS = _POST_ATTEMPTS
163
+
164
+ def __init__(self, config: 'Configuration', client: 'ApiClient'):
165
+ """Initialize the charging manager.
166
+
167
+ Args:
168
+ config: Configuration instance
169
+ client: API client for making charge requests
170
+ """
171
+ self._config = config
172
+ # Optional in practice: guards treat a missing client as "cannot bill".
173
+ self._client: Optional['ApiClient'] = client
174
+ self._pricing_info: Optional[PayPerEventActorPricingInfo] = None
175
+ self._charged_events: Dict[str, int] = {}
176
+ self._max_total_charge_usd: Optional[Decimal] = None
177
+ self._total_charged_usd: Decimal = Decimal('0.0')
178
+ self._charge_lock = RLock()
179
+ self._flush_lock = asyncio.Lock()
180
+ # Serializes budget calculation/reservation with PPE-aware dataset pushes.
181
+ # The threading lock protects synchronous state mutation; this async lock
182
+ # protects invariants across awaited push operations.
183
+ self._operation_lock = asyncio.Lock()
184
+ # Set during actor shutdown; rejects new charge operations after final flush.
185
+ self._closed = False
186
+ self._per_event_prices: Dict[str, Decimal] = {}
187
+ self._is_at_home = bool(config.get('is_at_home'))
188
+ # Prefer run_id from the API client (storage credentials) over env config.
189
+ client_run_id = getattr(client, 'run_id', None) if client is not None else None
190
+ self._actor_run_id: Optional[str] = client_run_id or config.get('actor_run_id')
191
+ self._initialized = False
192
+ self._not_ppe_warning_printed = False
193
+ # Unresolved platform-billable intents: idempotency_key -> metadata.
194
+ # Identity is the UUID key only — never keyed by event_name.
195
+ self._pending_charge_keys: Dict[str, Dict[str, Any]] = {}
196
+ # Optional local charging-log dataset (ACTOR_USE_CHARGING_LOG_DATASET).
197
+ self._charging_log_dataset: Any = None
198
+
199
+ def close(self) -> None:
200
+ """Mark the manager closed; subsequent charge/push operations are rejected."""
201
+ self._closed = True
202
+
203
+ @property
204
+ def charge_lock(self) -> RLock:
205
+ """Lock to synchronize charge operations."""
206
+ return self._charge_lock
207
+
208
+ def _load_pricing_data(self, pricing_data: Any) -> None:
209
+ """Populate PPE price map from a pricing info object/dict."""
210
+ if not isinstance(pricing_data, dict):
211
+ return
212
+
213
+ # Worker may inject either the full ActorPricingInfo or just pricingPerEvent.
214
+ model = pricing_data.get('pricingModel')
215
+ pricing_per_event = pricing_data.get('pricingPerEvent') or {}
216
+ actor_charge_events = pricing_per_event.get('actorChargeEvents') or pricing_per_event
217
+
218
+ if model and model != PricingModel.PAY_PER_EVENT.value and model != 'PAY_PER_EVENT':
219
+ return
220
+ if not actor_charge_events and model not in (PricingModel.PAY_PER_EVENT.value, 'PAY_PER_EVENT'):
221
+ return
222
+
223
+ per_event = {}
224
+ for event_name, event_data in (actor_charge_events or {}).items():
225
+ if not isinstance(event_data, dict):
226
+ continue
227
+ price = event_data.get('eventPriceUsd', event_data.get('event_price_usd', 0))
228
+ per_event[event_name] = PricingPerEvent(
229
+ event_price_usd=Decimal(str(price or 0)),
230
+ event_description=event_data.get('eventDescription') or event_data.get('eventTitle'),
231
+ )
232
+
233
+ if not per_event and model not in (PricingModel.PAY_PER_EVENT.value, 'PAY_PER_EVENT'):
234
+ return
235
+
236
+ self._pricing_info = PayPerEventActorPricingInfo(pricing_per_event=per_event)
237
+ self._per_event_prices = {
238
+ name: info.event_price_usd
239
+ for name, info in per_event.items()
240
+ }
241
+
242
+ default_event = pricing_data.get('defaultDatasetItemEventName')
243
+ if default_event and default_event not in self._per_event_prices:
244
+ self._per_event_prices[default_event] = Decimal('0')
245
+
246
+ async def init(self) -> None:
247
+ """Initialize the charging manager (JS ChargingManager.init parity).
248
+
249
+ Loads pricing info, prior charged counts and the budget from the
250
+ environment (when complete) or the platform run API, honors
251
+ ACTOR_TEST_PAY_PER_EVENT, and opens the optional charging-log dataset.
252
+ """
253
+ if self._initialized:
254
+ return
255
+
256
+ use_charging_log_dataset = bool(self._config.get('use_charging_log_dataset'))
257
+ if use_charging_log_dataset and self._is_at_home:
258
+ raise RuntimeError(
259
+ 'Using the ACTOR_USE_CHARGING_LOG_DATASET environment variable is only supported '
260
+ 'in a local development environment'
261
+ )
262
+
263
+ test_pay_per_event = bool(self._config.get('test_pay_per_event'))
264
+ if test_pay_per_event and self._is_at_home:
265
+ raise RuntimeError(
266
+ 'Using the ACTOR_TEST_PAY_PER_EVENT environment variable is only supported '
267
+ 'in a local development environment'
268
+ )
269
+
270
+ pricing_data = await self._fetch_pricing_data()
271
+
272
+ # Load max total charge (absent/zero means no cap — JS `|| Infinity` parity).
273
+ max_charge = pricing_data.get('max_total_charge_usd')
274
+ self._max_total_charge_usd = Decimal(str(max_charge)) if max_charge else None
275
+
276
+ # Load pricing info (env when complete, otherwise the run API at home).
277
+ pricing_info_raw = pricing_data.get('pricing_info')
278
+ if pricing_info_raw:
279
+ try:
280
+ parsed = json.loads(pricing_info_raw) if isinstance(pricing_info_raw, str) else pricing_info_raw
281
+ self._load_pricing_data(parsed)
282
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
283
+ logger.warning(f'Failed to parse actor pricing info: {e}')
284
+
285
+ # ACTOR_TEST_PAY_PER_EVENT forces pay-per-event mode locally even
286
+ # without configured pricing (every event then costs $1).
287
+ if test_pay_per_event and self._pricing_info is None:
288
+ self._pricing_info = PayPerEventActorPricingInfo(pricing_per_event={})
289
+
290
+ # Load charged event counts
291
+ charged_counts = pricing_data.get('charged_event_counts')
292
+ if charged_counts:
293
+ try:
294
+ if isinstance(charged_counts, str):
295
+ self._charged_events = json.loads(charged_counts)
296
+ else:
297
+ self._charged_events = dict(charged_counts)
298
+ except (json.JSONDecodeError, TypeError):
299
+ logger.warning('Failed to parse charged event counts')
300
+
301
+ # Recompute total charged from prior counts when prices are known.
302
+ self._total_charged_usd = Decimal('0.0')
303
+ for event_name, event_count in self._charged_events.items():
304
+ price = self._per_event_prices.get(event_name, Decimal('0'))
305
+ self._total_charged_usd += price * Decimal(str(event_count or 0))
306
+
307
+ self._initialized = True
308
+
309
+ # Optional charging-log dataset (pay-per-event runs only). The platform
310
+ # branch of the JS SDK is unreachable here because the environment
311
+ # variable is rejected at home above.
312
+ if self._pricing_info is not None and use_charging_log_dataset:
313
+ try:
314
+ await self._open_charging_log_dataset()
315
+ except Exception as e:
316
+ logger.warning('Failed to open the charging log dataset: %s', e)
317
+
318
+ async def _fetch_pricing_data(self) -> Dict[str, Any]:
319
+ """Resolve pricing info, charged counts and budget (JS fetchPricingInfo parity).
320
+
321
+ Prefers environment variables when both pricing info and charged counts are
322
+ present; on the platform an incomplete configuration falls back to the run API.
323
+ """
324
+ pricing_info_raw = self._config.get('actor_pricing_info')
325
+ charged_counts = self._config.get('charged_event_counts')
326
+ if pricing_info_raw and charged_counts:
327
+ return {
328
+ 'pricing_info': pricing_info_raw,
329
+ 'charged_event_counts': charged_counts,
330
+ 'max_total_charge_usd': self._config.get('max_total_charge_usd'),
331
+ }
332
+
333
+ if self._is_at_home:
334
+ if not self._actor_run_id:
335
+ raise RuntimeError(
336
+ 'Actor run ID not found even though the Actor is running on the platform'
337
+ )
338
+ if self._client is not None:
339
+ run = None
340
+ try:
341
+ run = await self._client.get_json(f'/v2/actor-runs/{self._actor_run_id}')
342
+ except Exception as e:
343
+ logger.warning(
344
+ 'Failed to fetch actor run %s for pricing info: %s',
345
+ self._actor_run_id,
346
+ e,
347
+ )
348
+ if run is None:
349
+ raise RuntimeError('Actor run not found')
350
+ options = run.get('options') or {}
351
+ return {
352
+ 'pricing_info': run.get('pricingInfo'),
353
+ 'charged_event_counts': run.get('chargedEventCounts') or {},
354
+ 'max_total_charge_usd': options.get('maxTotalChargeUsd'),
355
+ }
356
+ logger.warning('No API client available; cannot fetch run pricing info')
357
+
358
+ return {
359
+ 'pricing_info': pricing_info_raw,
360
+ 'charged_event_counts': charged_counts,
361
+ 'max_total_charge_usd': self._config.get('max_total_charge_usd'),
362
+ }
363
+
364
+ def _calculate_event_price(self, event_name: str) -> Optional[Decimal]:
365
+ """Effective unit price for affordability (JS calculateEventPrice parity).
366
+
367
+ Locally every event costs $1 so budgets constrain during development;
368
+ on the platform unregistered events are free (None).
369
+ """
370
+ if not self._is_at_home:
371
+ return Decimal('1')
372
+ if is_default_dataset_item_event(event_name):
373
+ if APIFY_DEFAULT_DATASET_ITEM_EVENT in self._per_event_prices:
374
+ return self._per_event_prices[APIFY_DEFAULT_DATASET_ITEM_EVENT]
375
+ return self._per_event_prices.get(SCRAPELY_DEFAULT_DATASET_ITEM_EVENT)
376
+ return self._per_event_prices.get(event_name)
377
+
378
+ def _get_event_pricing(self, event_name: str) -> tuple[Decimal, str]:
379
+ """Unit price and title used for reservation/logging (JS getEventPricing parity)."""
380
+ canonical_event_name = (
381
+ resolve_default_dataset_item_event_name(self._per_event_prices)
382
+ if is_default_dataset_item_event(event_name)
383
+ else event_name
384
+ )
385
+
386
+ price = self._per_event_prices.get(canonical_event_name)
387
+ if price is not None:
388
+ info = (
389
+ self._pricing_info.pricing_per_event.get(canonical_event_name)
390
+ if self._pricing_info is not None
391
+ else None
392
+ )
393
+ title = info.event_description if info is not None and info.event_description else canonical_event_name
394
+ return price, title
395
+
396
+ # Unknown event: free on the platform, $1 locally.
397
+ price = Decimal('0') if self._is_at_home else Decimal('1')
398
+ return price, f"Unknown event '{event_name}'"
399
+
400
+ async def _open_charging_log_dataset(self) -> None:
401
+ """Open the local charging-log dataset (JS parity: local development only)."""
402
+ from crawlee.storages import Dataset
403
+
404
+ if self._config.get('purge_on_start'):
405
+ dataset = await Dataset.open(name=LOCAL_CHARGING_LOG_DATASET_NAME)
406
+ await dataset.drop()
407
+ self._charging_log_dataset = await Dataset.open(name=LOCAL_CHARGING_LOG_DATASET_NAME)
408
+
409
+ def _snapshot_pending(self) -> List[tuple[str, Dict[str, Any]]]:
410
+ with self._charge_lock:
411
+ return [(k, dict(v)) for k, v in self._pending_charge_keys.items()]
412
+
413
+ def _drop_pending(self, idempotency_key: str) -> None:
414
+ with self._charge_lock:
415
+ self._pending_charge_keys.pop(idempotency_key, None)
416
+
417
+ def _unwind_reservation(self, event_name: str, count: int) -> None:
418
+ """Subtract a previously reserved local count/spend (reject or partial accept)."""
419
+ if count <= 0:
420
+ return
421
+ with self._charge_lock:
422
+ event_price = self._per_event_prices.get(event_name, Decimal('0'))
423
+ if event_name in self._charged_events:
424
+ self._charged_events[event_name] = max(
425
+ 0, self._charged_events[event_name] - count
426
+ )
427
+ self._total_charged_usd = max(
428
+ Decimal('0'),
429
+ self._total_charged_usd - event_price * Decimal(count),
430
+ )
431
+
432
+ def _quarantine_stale_pending(self) -> None:
433
+ """Remove intents older than the safe replay window (fail closed, no replay).
434
+
435
+ Unwinds the local reservation so quarantining cannot permanently consume budget.
436
+ """
437
+ now = time.monotonic()
438
+ stale: List[tuple[str, Dict[str, Any]]] = []
439
+ with self._charge_lock:
440
+ for key, pending in self._pending_charge_keys.items():
441
+ age = now - float(pending.get('created_at', now) or now)
442
+ if age > self._IDEMPOTENCY_SAFE_AGE_SECS:
443
+ stale.append((key, dict(pending)))
444
+ for key, _pending in stale:
445
+ self._pending_charge_keys.pop(key, None)
446
+ for key, pending in stale:
447
+ event_name = str(pending.get('event_name') or '')
448
+ count = int(pending.get('count', 0) or 0)
449
+ if event_name and count > 0:
450
+ self._unwind_reservation(event_name, count)
451
+ logger.error(
452
+ 'Quarantined stale charge intent past safe idempotency age '
453
+ '(key=%s event=%s count=%s); unwound local reservation; will not replay',
454
+ key,
455
+ pending.get('event_name'),
456
+ pending.get('count'),
457
+ )
458
+
459
+ async def _post_charge_once(
460
+ self,
461
+ *,
462
+ event_name: str,
463
+ count: int,
464
+ idempotency_key: str,
465
+ ) -> Dict[str, Any]:
466
+ if self._client is None:
467
+ raise RuntimeError('ChargingManager has no API client; cannot post charge intent')
468
+ response = await self._client.post(
469
+ f'/v2/actor-runs/{self._actor_run_id}/charge',
470
+ json_data={
471
+ 'eventName': event_name,
472
+ 'count': count,
473
+ },
474
+ headers={'Idempotency-Key': idempotency_key},
475
+ )
476
+ if isinstance(response, dict):
477
+ return response
478
+ # httpx.Response-like
479
+ try:
480
+ body = response.json()
481
+ if isinstance(body, dict):
482
+ return body
483
+ except Exception:
484
+ pass
485
+ return {}
486
+
487
+ async def _post_charge_with_retries(
488
+ self,
489
+ *,
490
+ event_name: str,
491
+ count: int,
492
+ idempotency_key: str,
493
+ ) -> Optional[int]:
494
+ """POST charge; return acceptedCount on success, None if uncertain.
495
+
496
+ Raises ChargeRejectedError for definitive platform rejections.
497
+ Same idempotency key on every attempt.
498
+ """
499
+ last_error: Optional[BaseException] = None
500
+ for attempt in range(self._POST_ATTEMPTS):
501
+ try:
502
+ body = await self._post_charge_once(
503
+ event_name=event_name,
504
+ count=count,
505
+ idempotency_key=idempotency_key,
506
+ )
507
+ return _read_accepted_count(body, count)
508
+ except Exception as e:
509
+ last_error = e
510
+ rejected = _classify_charge_http_error(
511
+ e,
512
+ operation_id=idempotency_key,
513
+ event_name=event_name,
514
+ count=count,
515
+ )
516
+ if rejected is not None:
517
+ raise rejected from e
518
+ if attempt + 1 < self._POST_ATTEMPTS:
519
+ await asyncio.sleep(0.25 * (attempt + 1))
520
+ if last_error is not None:
521
+ logger.warning(
522
+ 'Failed to record charge for event %s (count=%s, key=%s): %s',
523
+ event_name,
524
+ count,
525
+ idempotency_key,
526
+ last_error,
527
+ )
528
+ return None
529
+
530
+ async def flush_pending(
531
+ self, *, max_intents: int = 32, deadline_ms: float | None = None
532
+ ) -> int:
533
+ """Best-effort flush under the operation lock; drain until empty or deadline."""
534
+ if not self._is_at_home or self._client is None or not self._actor_run_id:
535
+ return 0
536
+ async with self._operation_lock:
537
+ return await self._flush_pending_exclusive(
538
+ max_intents=max_intents, deadline_ms=deadline_ms
539
+ )
540
+
541
+ async def _flush_pending_exclusive(
542
+ self, *, max_intents: int = 32, deadline_ms: float | None = None
543
+ ) -> int:
544
+ """Caller must hold _operation_lock."""
545
+ if not self._is_at_home or self._client is None or not self._actor_run_id:
546
+ return 0
547
+
548
+ import time
549
+
550
+ deadline = None if deadline_ms is None else (time.monotonic() + max(0.0, deadline_ms) / 1000.0)
551
+ confirmed_total = 0
552
+ async with self._flush_lock:
553
+ while True:
554
+ if deadline is not None and time.monotonic() >= deadline:
555
+ break
556
+ self._quarantine_stale_pending()
557
+ snapshot = self._snapshot_pending()[: max(0, max_intents)]
558
+ if not snapshot:
559
+ break
560
+ confirmed = 0
561
+ for idempotency_key, pending in snapshot:
562
+ if deadline is not None and time.monotonic() >= deadline:
563
+ break
564
+ event_name = str(pending.get('event_name') or '')
565
+ count = int(pending.get('count', 0) or 0)
566
+ if not event_name or count <= 0:
567
+ self._drop_pending(idempotency_key)
568
+ continue
569
+ try:
570
+ accepted = await self._post_charge_with_retries(
571
+ event_name=event_name,
572
+ count=count,
573
+ idempotency_key=idempotency_key,
574
+ )
575
+ except ChargeRejectedError as err:
576
+ self._unwind_reservation(event_name, count)
577
+ self._drop_pending(idempotency_key)
578
+ logger.warning(
579
+ 'Dropping rejected pending charge (event=%s key=%s): %s',
580
+ event_name,
581
+ idempotency_key,
582
+ err,
583
+ )
584
+ continue
585
+ if accepted is not None:
586
+ if accepted < count:
587
+ self._unwind_reservation(event_name, count - accepted)
588
+ self._drop_pending(idempotency_key)
589
+ confirmed += 1
590
+ confirmed_total += confirmed
591
+ if confirmed == 0:
592
+ break
593
+ return confirmed_total
594
+
595
+ async def charge(self, event_name: str, count: int = 1) -> ChargeResult:
596
+ """Atomically reserve and submit one logical charge operation."""
597
+ async with self._operation_lock:
598
+ return await self._charge_exclusive(event_name, count)
599
+
600
+ async def _charge_exclusive(self, event_name: str, count: int = 1) -> ChargeResult:
601
+ if self._closed:
602
+ raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
603
+ """Charge for a specified number of events.
604
+
605
+ Args:
606
+ event_name: Name of the event to charge
607
+ count: Number of events to charge
608
+
609
+ Returns:
610
+ ChargeResult indicating if the limit was reached
611
+
612
+ Raises:
613
+ ChargeUncertainError: POST could not be confirmed after retries; the
614
+ operation remains reserved locally and pending for flush.
615
+ """
616
+ if not self._initialized:
617
+ await self.init()
618
+
619
+ # Not a PPE actor — ignore charges (matches JS ChargingManager).
620
+ if self._pricing_info is None:
621
+ if not self._not_ppe_warning_printed:
622
+ logger.warning(
623
+ 'Ignored attempt to charge for an event - the Actor does not use the pay-per-event pricing'
624
+ )
625
+ self._not_ppe_warning_printed = True
626
+ return ChargeResult(
627
+ chargeable_within_limit={},
628
+ charged_count=0,
629
+ event_charge_limit_reached=False,
630
+ )
631
+
632
+ if not isinstance(count, int) or isinstance(count, bool) or count < 1:
633
+ raise ValueError('count must be a positive integer')
634
+
635
+ # Best-effort: clear older unresolved intents before starting a new one.
636
+ try:
637
+ await self._flush_pending_exclusive()
638
+ except Exception as e:
639
+ logger.warning('flush_pending before charge failed: %s', e)
640
+
641
+ idempotency_key: Optional[str] = None
642
+ chargeable_count = 0
643
+ will_post = False
644
+
645
+ with self._charge_lock:
646
+ event_price, event_title = self._get_event_pricing(event_name)
647
+ affordability_price = self._calculate_event_price(event_name) or Decimal('0')
648
+
649
+ # JS ChargingManager parity: canPost requires isAtHome — a local
650
+ # process with an API client/run ID must never post platform charges.
651
+ can_post_base = (
652
+ self._is_at_home
653
+ and bool(self._actor_run_id)
654
+ and self._client is not None
655
+ and not is_default_dataset_item_event(event_name)
656
+ and event_name in self._per_event_prices
657
+ )
658
+
659
+ # Budget gate (JS parity): trim unaffordable counts, but keep the
660
+ # legacy one-event overdraw while the budget is not yet exceeded.
661
+ chargeable_count = count
662
+ if self._max_total_charge_usd is not None and affordability_price > 0:
663
+ remaining = self._max_total_charge_usd - self._total_charged_usd
664
+ max_count_by_limit = max(0, int(remaining / affordability_price))
665
+ if count > max_count_by_limit:
666
+ chargeable_count = (
667
+ max_count_by_limit + 1
668
+ if self._total_charged_usd <= self._max_total_charge_usd
669
+ else 0
670
+ )
671
+
672
+ if chargeable_count <= 0:
673
+ return ChargeResult(
674
+ chargeable_within_limit=self._compute_chargeable(),
675
+ charged_count=0,
676
+ event_charge_limit_reached=True,
677
+ )
678
+
679
+ will_post = can_post_base and chargeable_count > 0
680
+
681
+ if will_post:
682
+ if len(self._pending_charge_keys) >= self._PENDING_MAX_ENTRIES:
683
+ logger.warning(
684
+ 'Too many unresolved charge intents (%s); refusing new '
685
+ 'billable charge for event %s until pending POSTs succeed',
686
+ len(self._pending_charge_keys),
687
+ event_name,
688
+ )
689
+ return ChargeResult(
690
+ chargeable_within_limit=self._compute_chargeable(),
691
+ charged_count=0,
692
+ event_charge_limit_reached=False,
693
+ )
694
+ idempotency_key = str(uuid.uuid4())
695
+ self._pending_charge_keys[idempotency_key] = {
696
+ 'idempotency_key': idempotency_key,
697
+ 'created_at': time.monotonic(),
698
+ 'event_name': event_name,
699
+ 'count': chargeable_count,
700
+ }
701
+
702
+ # Reserve local budget once per logical operation.
703
+ if event_name not in self._charged_events:
704
+ self._charged_events[event_name] = 0
705
+ self._charged_events[event_name] += chargeable_count
706
+ self._total_charged_usd += event_price * Decimal(chargeable_count)
707
+
708
+ result = ChargeResult(
709
+ chargeable_within_limit=self._compute_chargeable(),
710
+ charged_count=chargeable_count,
711
+ event_charge_limit_reached=self._is_event_limit_reached(event_name),
712
+ )
713
+
714
+ # Record charge with the platform.
715
+ # Synthetic default-dataset events are tracked locally only.
716
+ if will_post and idempotency_key is not None:
717
+ try:
718
+ accepted = await self._post_charge_with_retries(
719
+ event_name=event_name,
720
+ count=chargeable_count,
721
+ idempotency_key=idempotency_key,
722
+ )
723
+ except ChargeRejectedError:
724
+ # Server definitively rejected: unwind local reservation.
725
+ self._drop_pending(idempotency_key)
726
+ self._unwind_reservation(event_name, chargeable_count)
727
+ return ChargeResult(
728
+ chargeable_within_limit=self._compute_chargeable(),
729
+ charged_count=0,
730
+ event_charge_limit_reached=True,
731
+ )
732
+ if accepted is not None:
733
+ self._drop_pending(idempotency_key)
734
+ if accepted < chargeable_count:
735
+ self._unwind_reservation(event_name, chargeable_count - accepted)
736
+ chargeable_count = accepted
737
+ result = ChargeResult(
738
+ chargeable_within_limit=self._compute_chargeable(),
739
+ charged_count=chargeable_count,
740
+ event_charge_limit_reached=self._is_event_limit_reached(event_name),
741
+ )
742
+ else:
743
+ raise ChargeUncertainError(
744
+ f'Charge for event {event_name!r} (count={chargeable_count}) '
745
+ f'was not confirmed by the platform; operation_id={idempotency_key}',
746
+ operation_id=idempotency_key,
747
+ event_name=event_name,
748
+ count=chargeable_count,
749
+ )
750
+ elif (
751
+ chargeable_count > 0
752
+ and self._is_at_home
753
+ and not is_default_dataset_item_event(event_name)
754
+ and event_name not in self._per_event_prices
755
+ and self._pricing_info is not None
756
+ ):
757
+ logger.warning("Attempting to charge for an unknown event '%s'", event_name)
758
+
759
+ if self._charging_log_dataset is not None:
760
+ try:
761
+ await self._charging_log_dataset.push_data({
762
+ 'eventName': event_name,
763
+ 'eventTitle': event_title,
764
+ 'eventPriceUsd': float(event_price),
765
+ 'chargedCount': chargeable_count,
766
+ 'timestamp': datetime.now(timezone.utc).isoformat(),
767
+ })
768
+ except Exception as log_err:
769
+ logger.warning('Failed to append charging log entry: %s', log_err)
770
+
771
+ if chargeable_count < count:
772
+ subject = 'instance' if count == 1 else 'instances'
773
+ logger.info(
774
+ "Charging %s %s of '%s' event would exceed maxTotalChargeUsd - "
775
+ 'only %s events were charged',
776
+ count,
777
+ subject,
778
+ event_name,
779
+ chargeable_count,
780
+ )
781
+
782
+ return result
783
+
784
+ def calculate_max_event_charge_count_within_limit(self, event_name: str) -> Optional[int]:
785
+ """Calculate how many events can be charged before reaching the limit.
786
+
787
+ Args:
788
+ event_name: Name of the inspected event
789
+
790
+ Returns:
791
+ Maximum count that can be charged, or None if no limit
792
+ """
793
+ if self._max_total_charge_usd is None:
794
+ return None
795
+
796
+ event_price = self._calculate_event_price(event_name)
797
+ if event_price is None or event_price <= 0:
798
+ return None
799
+
800
+ remaining = self._max_total_charge_usd - self._total_charged_usd
801
+ return max(0, int(remaining / event_price))
802
+
803
+ def calculate_total_charged_amount(self) -> Decimal:
804
+ """Calculate the total amount charged so far.
805
+
806
+ Returns:
807
+ Total charged amount in USD
808
+ """
809
+ return self._total_charged_usd
810
+
811
+ def compute_chargeable(self) -> Dict[str, Optional[int]]:
812
+ """Compute max events of each type that can be charged within budget.
813
+
814
+ Returns:
815
+ Dictionary of event names to maximum chargeable counts
816
+ """
817
+ result = {}
818
+ for event_name in self._per_event_prices:
819
+ result[event_name] = self.calculate_max_event_charge_count_within_limit(event_name)
820
+ return result
821
+
822
+ def compute_push_data_limit(
823
+ self,
824
+ items_count: int,
825
+ event_name: str,
826
+ *,
827
+ is_default_dataset: bool = False,
828
+ ) -> int:
829
+ """Compute how many items can be pushed and charged within budget.
830
+
831
+ Accounts for both the explicit event and the default dataset item event.
832
+
833
+ Args:
834
+ items_count: Number of items to push
835
+ event_name: Explicit event name to charge for each item
836
+ is_default_dataset: Whether pushing to default dataset
837
+
838
+ Returns:
839
+ Maximum number of items that can be pushed within budget
840
+ """
841
+ # Calculate combined price per item (effective prices: $1 per event locally)
842
+ explicit_price = (
843
+ self._calculate_event_price(event_name) or Decimal('0') if event_name else Decimal('0')
844
+ )
845
+ default_price = Decimal('0')
846
+
847
+ if is_default_dataset:
848
+ default_event = self._config.get('default_dataset_item_event_name')
849
+ if not default_event:
850
+ default_event = resolve_default_dataset_item_event_name(self._per_event_prices)
851
+ if default_event:
852
+ default_price = self._calculate_event_price(default_event) or Decimal('0')
853
+
854
+ combined_price = explicit_price + default_price
855
+
856
+ if combined_price <= 0:
857
+ return items_count
858
+
859
+ if self._max_total_charge_usd is None:
860
+ return items_count
861
+
862
+ remaining = self._max_total_charge_usd - self._total_charged_usd
863
+ max_items = int(remaining / combined_price)
864
+
865
+ return min(items_count, max(0, max_items))
866
+
867
+ def get_charged_event_count(self, event_name: str) -> int:
868
+ """Get the number of events charged so far.
869
+
870
+ Args:
871
+ event_name: Name of the inspected event
872
+
873
+ Returns:
874
+ Number of events charged
875
+ """
876
+ if is_default_dataset_item_event(event_name):
877
+ return (
878
+ self._charged_events.get(APIFY_DEFAULT_DATASET_ITEM_EVENT, 0)
879
+ + self._charged_events.get(SCRAPELY_DEFAULT_DATASET_ITEM_EVENT, 0)
880
+ )
881
+ return self._charged_events.get(event_name, 0)
882
+
883
+ def get_max_total_charge_usd(self) -> Decimal:
884
+ """Get the configured maximum total charge.
885
+
886
+ Returns:
887
+ Maximum total charge in USD
888
+ """
889
+ # No configured cap (or zero) means unlimited — JS returns Infinity.
890
+ return self._max_total_charge_usd if self._max_total_charge_usd else Decimal('Infinity')
891
+
892
+ def get_pricing_info(self) -> ActorPricingInfoResult:
893
+ """Retrieve detailed pricing information for the current Actor run.
894
+
895
+ Returns:
896
+ ActorPricingInfoResult with pricing details
897
+ """
898
+ return ActorPricingInfoResult(
899
+ is_pay_per_event=self._pricing_info is not None,
900
+ max_total_charge_usd=(
901
+ self.get_max_total_charge_usd()
902
+ if self.get_max_total_charge_usd().is_finite()
903
+ else float('inf')
904
+ ),
905
+ per_event_prices=self._per_event_prices,
906
+ pricing_model=PricingModel.PAY_PER_EVENT if self._pricing_info else None,
907
+ )
908
+
909
+ def is_event_charge_limit_reached(self, event_name: str) -> bool:
910
+ """Check if the budget is insufficient to charge even one more event.
911
+
912
+ Args:
913
+ event_name: Name of the event to check
914
+
915
+ Returns:
916
+ True if no more events of this type can be charged
917
+ """
918
+ event_price = self._calculate_event_price(event_name) or Decimal('0')
919
+
920
+ if self._max_total_charge_usd is None:
921
+ return False
922
+
923
+ remaining = self._max_total_charge_usd - self._total_charged_usd
924
+ return remaining < event_price
925
+
926
+ def _compute_chargeable(self) -> Dict[str, Optional[int]]:
927
+ """Internal method to compute chargeable counts."""
928
+ return self.compute_chargeable()
929
+
930
+ def _is_event_limit_reached(self, event_name: str) -> bool:
931
+ """Internal method to check if event limit is reached."""
932
+ return self.is_event_charge_limit_reached(event_name)
933
+
934
+ def get_charged_events(self) -> Dict[str, int]:
935
+ """Get the count of charged events by event name.
936
+
937
+ Returns:
938
+ Dictionary of event names to counts
939
+ """
940
+ return self._charged_events.copy()
941
+
942
+ def get_total_charged_usd(self) -> Decimal:
943
+ """Get the total amount charged in USD.
944
+
945
+ Returns:
946
+ Total charged amount
947
+ """
948
+ return self._total_charged_usd
949
+
950
+
951
+
952
+ def _read_accepted_count(body: Any, fallback: int) -> int:
953
+ if not isinstance(body, dict):
954
+ return fallback
955
+ # Support both bare body and {data: ...} envelopes.
956
+ data = body.get('data') if isinstance(body.get('data'), dict) else body
957
+ raw = data.get('acceptedCount') if isinstance(data, dict) else None
958
+ if isinstance(raw, (int, float)) and raw >= 0:
959
+ return int(raw)
960
+ return fallback
961
+
962
+
963
+ def _classify_charge_http_error(
964
+ err: BaseException,
965
+ *,
966
+ operation_id: str,
967
+ event_name: str,
968
+ count: int,
969
+ ) -> Optional[ChargeRejectedError]:
970
+ status = getattr(err, 'status_code', None)
971
+ if status is None:
972
+ response = getattr(err, 'response', None)
973
+ status = getattr(response, 'status_code', None) if response is not None else None
974
+
975
+ # 401/403: ownership or scoped-run authorization failures are permanent.
976
+ if status in (400, 401, 402, 403, 404, 405, 409):
977
+ return ChargeRejectedError(
978
+ f'Charge rejected by platform (HTTP {status}) for event {event_name!r}',
979
+ operation_id=operation_id,
980
+ event_name=event_name,
981
+ count=count,
982
+ status_code=int(status) if status is not None else None,
983
+ reason='charge-limit-exceeded' if status == 402 else f'http-{status}',
984
+ )
985
+
986
+ message = str(err)
987
+ if any(
988
+ token in message.lower()
989
+ for token in (
990
+ 'charge-limit-exceeded',
991
+ 'unknown-event',
992
+ 'ledger-sealed',
993
+ 'invalid-input',
994
+ 'conflict',
995
+ )
996
+ ):
997
+ return ChargeRejectedError(
998
+ message,
999
+ operation_id=operation_id,
1000
+ event_name=event_name,
1001
+ count=count,
1002
+ status_code=int(status) if isinstance(status, int) else None,
1003
+ reason='message-match',
1004
+ )
1005
+ return None
1006
+
1007
+
1008
+ async def push_data_and_charge(
1009
+ charging_manager: ChargingManager,
1010
+ items: Union[Dict[str, Any], List[Dict[str, Any]]],
1011
+ event_name: Optional[str],
1012
+ is_default_dataset: bool,
1013
+ push_fn: Any,
1014
+ ) -> ChargeResult:
1015
+ """Push data to dataset and charge for each item.
1016
+
1017
+ Limits items to remaining budget before push (JS parity).
1018
+
1019
+ Args:
1020
+ charging_manager: Charging manager instance
1021
+ items: Items to push
1022
+ event_name: Event name for charging
1023
+ is_default_dataset: Whether pushing to default dataset
1024
+ push_fn: Function to push items
1025
+
1026
+ Returns:
1027
+ ChargeResult
1028
+ """
1029
+ if not isinstance(items, list):
1030
+ items = [items]
1031
+
1032
+ async with charging_manager._operation_lock:
1033
+ if charging_manager._closed:
1034
+ raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
1035
+ # Non-PPE actors push everything without charging (JS calculatePushDataLimits
1036
+ # parity: `if (!this.isPayPerEvent) return { limitedItems, eventsToCharge: {} }`).
1037
+ is_ppe = charging_manager._pricing_info is not None
1038
+
1039
+ resolved_event = event_name
1040
+ if is_ppe and not resolved_event and is_default_dataset:
1041
+ fallback_event = charging_manager._config.get('default_dataset_item_event_name')
1042
+ if not fallback_event:
1043
+ fallback_event = resolve_default_dataset_item_event_name(
1044
+ charging_manager._per_event_prices
1045
+ )
1046
+ if fallback_event:
1047
+ resolved_event = fallback_event
1048
+
1049
+ limited_count = len(items)
1050
+ if is_ppe and resolved_event:
1051
+ # Keep the explicit event separate from the implicit default-dataset
1052
+ # event. Passing resolved_event here would count the default price twice.
1053
+ limited_count = charging_manager.compute_push_data_limit(
1054
+ len(items),
1055
+ event_name or '',
1056
+ is_default_dataset=is_default_dataset,
1057
+ )
1058
+
1059
+ limited_items = items[:limited_count] if limited_count < len(items) else items
1060
+
1061
+ if not limited_items:
1062
+ return ChargeResult(
1063
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1064
+ charged_count=0,
1065
+ event_charge_limit_reached=len(items) > 0 and limited_count == 0,
1066
+ )
1067
+
1068
+ # Charge BEFORE persisting so a server cap/reject cannot leave unbilled
1069
+ # items in the dataset. Operation lock is held across both steps.
1070
+ if is_ppe and resolved_event:
1071
+ results: List[ChargeResult] = []
1072
+ default_event: Optional[str] = None
1073
+ if is_default_dataset:
1074
+ default_event = charging_manager._config.get('default_dataset_item_event_name')
1075
+ if not default_event:
1076
+ default_event = resolve_default_dataset_item_event_name(
1077
+ charging_manager._per_event_prices
1078
+ )
1079
+
1080
+ # Synthetic storage event first (local-only); then explicit billable event.
1081
+ reserved_in_op: list[tuple[str, int]] = []
1082
+ try:
1083
+ if default_event:
1084
+ r = await charging_manager._charge_exclusive(default_event, len(limited_items))
1085
+ results.append(r)
1086
+ reserved_in_op.append((default_event, r.charged_count))
1087
+ if event_name and event_name != default_event:
1088
+ r = await charging_manager._charge_exclusive(event_name, len(limited_items))
1089
+ results.append(r)
1090
+ reserved_in_op.append((event_name, r.charged_count))
1091
+ except Exception:
1092
+ for name, reserved in reserved_in_op:
1093
+ if reserved > 0:
1094
+ charging_manager._unwind_reservation(name, reserved)
1095
+ raise
1096
+
1097
+ if results:
1098
+ # Pair each result with the event it reserved so we can unwind
1099
+ # over-reservations when the min accepted count is lower.
1100
+ charged_events: List[tuple[str, ChargeResult]] = []
1101
+ if default_event:
1102
+ charged_events.append((default_event, results[0]))
1103
+ if event_name and event_name != default_event:
1104
+ charged_events.append((event_name, results[-1]))
1105
+
1106
+ accepted_for_items = min(
1107
+ len(limited_items),
1108
+ *(result.charged_count for result in results),
1109
+ )
1110
+ # Unwind any event that reserved more than we will persist
1111
+ # (synthetic default-dataset is local-only and otherwise sticks).
1112
+ for name, result in charged_events:
1113
+ if result.charged_count > accepted_for_items:
1114
+ charging_manager._unwind_reservation(
1115
+ name, result.charged_count - accepted_for_items
1116
+ )
1117
+ if accepted_for_items > 0:
1118
+ try:
1119
+ await push_fn(limited_items[:accepted_for_items])
1120
+ except Exception as push_err:
1121
+ logger.error(
1122
+ 'Dataset push failed after charge accepted (items=%s): %s',
1123
+ accepted_for_items,
1124
+ push_err,
1125
+ )
1126
+ raise
1127
+ return ChargeResult(
1128
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1129
+ charged_count=accepted_for_items * len(results) if results else 0,
1130
+ event_charge_limit_reached=any(
1131
+ result.event_charge_limit_reached for result in results
1132
+ )
1133
+ or accepted_for_items < len(limited_items),
1134
+ )
1135
+
1136
+
1137
+ # No billable events — still persist the budget-limited items.
1138
+ await push_fn(limited_items)
1139
+ return ChargeResult(
1140
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1141
+ charged_count=0,
1142
+ event_charge_limit_reached=False,
1143
+ )