scrapely-python 0.1.17__py3-none-any.whl → 0.1.18__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.
- scrapely/__init__.py +1 -1
- scrapely/actor.py +16 -0
- scrapely/charging.py +160 -0
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/METADATA +1 -1
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/RECORD +10 -10
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/WHEEL +0 -0
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/licenses/LICENSE +0 -0
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/licenses/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/licenses/NOTICE +0 -0
- {scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/top_level.txt +0 -0
scrapely/__init__.py
CHANGED
scrapely/actor.py
CHANGED
|
@@ -28,6 +28,7 @@ from .charging import (
|
|
|
28
28
|
ChargingManager,
|
|
29
29
|
is_default_dataset_item_event,
|
|
30
30
|
push_data_and_charge,
|
|
31
|
+
push_data_with_charges,
|
|
31
32
|
set_active_charging_manager,
|
|
32
33
|
)
|
|
33
34
|
from .configuration import Configuration
|
|
@@ -781,6 +782,7 @@ class Actor(metaclass=_ActorMeta):
|
|
|
781
782
|
charged_event_name: Optional[str] = None,
|
|
782
783
|
*,
|
|
783
784
|
event_name: Optional[str] = None,
|
|
785
|
+
charges: Optional[Dict[str, int]] = None,
|
|
784
786
|
) -> ChargeResult:
|
|
785
787
|
"""Push data to the default dataset, charging pay-per-event items.
|
|
786
788
|
|
|
@@ -796,6 +798,10 @@ class Actor(metaclass=_ActorMeta):
|
|
|
796
798
|
(Apify Python SDK name; also accepted positionally).
|
|
797
799
|
event_name: Deprecated alias of ``charged_event_name`` (JS ``eventName``),
|
|
798
800
|
kept so existing callers keep working.
|
|
801
|
+
charges: Several events per row, charged atomically: ``{'result': 1, 'page': 3}``
|
|
802
|
+
charges 1 ``result`` and 3 ``page`` events for every row. A row is stored
|
|
803
|
+
only when all of its events were charged; rows the spending limit cannot
|
|
804
|
+
pay for in full are not stored. Cannot be combined with ``charged_event_name``.
|
|
799
805
|
|
|
800
806
|
Returns:
|
|
801
807
|
ChargeResult describing how many items were charged and pushed.
|
|
@@ -818,6 +824,16 @@ class Actor(metaclass=_ActorMeta):
|
|
|
818
824
|
|
|
819
825
|
items = data if isinstance(data, list) else [data]
|
|
820
826
|
charging_manager = cls.get_charging_manager()
|
|
827
|
+
if charges is not None:
|
|
828
|
+
if event_name is not None:
|
|
829
|
+
raise ValueError('Pass either charged_event_name or charges, not both')
|
|
830
|
+
return await push_data_with_charges(
|
|
831
|
+
charging_manager,
|
|
832
|
+
items,
|
|
833
|
+
charges,
|
|
834
|
+
True, # Actor.push_data always targets the default dataset
|
|
835
|
+
cls._default_dataset.push_data,
|
|
836
|
+
)
|
|
821
837
|
return await push_data_and_charge(
|
|
822
838
|
charging_manager,
|
|
823
839
|
items,
|
scrapely/charging.py
CHANGED
|
@@ -1585,6 +1585,166 @@ async def push_data_and_charge(
|
|
|
1585
1585
|
)
|
|
1586
1586
|
|
|
1587
1587
|
|
|
1588
|
+
def _validate_row_charges(charges: Any) -> Dict[str, int]:
|
|
1589
|
+
if not isinstance(charges, dict) or not charges:
|
|
1590
|
+
raise ValueError('charges must be a non-empty dict of {event_name: count_per_row}')
|
|
1591
|
+
out: Dict[str, int] = {}
|
|
1592
|
+
for name, count in charges.items():
|
|
1593
|
+
if not isinstance(name, str) or not name:
|
|
1594
|
+
raise ValueError('charges keys must be event names')
|
|
1595
|
+
if is_default_dataset_item_event(name):
|
|
1596
|
+
raise ValueError(f"Cannot charge for synthetic event '{name}' manually")
|
|
1597
|
+
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
|
|
1598
|
+
raise ValueError(f'charges[{name!r}] must be a positive integer (events per row)')
|
|
1599
|
+
out[name] = count
|
|
1600
|
+
return out
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
async def push_data_with_charges(
|
|
1604
|
+
charging_manager: ChargingManager,
|
|
1605
|
+
items: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
1606
|
+
charges: Dict[str, int],
|
|
1607
|
+
is_default_dataset: bool,
|
|
1608
|
+
push_fn: Any,
|
|
1609
|
+
) -> ChargeResult:
|
|
1610
|
+
"""Push rows that each cost several events, atomically per row.
|
|
1611
|
+
|
|
1612
|
+
``charges`` maps event name -> events per row, e.g. ``{'result': 1, 'page': 3}``.
|
|
1613
|
+
A row is stored only when every one of its events was charged: the full set
|
|
1614
|
+
is charged first, rows the budget cannot fully pay for are not stored, and
|
|
1615
|
+
any operation that came back short is voided and re-charged for the rows
|
|
1616
|
+
that are kept, so no row is ever stored partly paid and no event is billed
|
|
1617
|
+
for a row that was not stored. Unpriced events are ignored. On the default
|
|
1618
|
+
dataset the default-dataset-item event (when priced) is added per row.
|
|
1619
|
+
"""
|
|
1620
|
+
charges = _validate_row_charges(charges)
|
|
1621
|
+
rows_in = items if isinstance(items, list) else [items]
|
|
1622
|
+
if not rows_in:
|
|
1623
|
+
return ChargeResult(chargeable_within_limit={}, charged_count=0, event_charge_limit_reached=False)
|
|
1624
|
+
|
|
1625
|
+
async with charging_manager._operation_lock:
|
|
1626
|
+
if charging_manager._closed:
|
|
1627
|
+
raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
|
|
1628
|
+
voids_confirmed = await charging_manager._retry_pending_voids()
|
|
1629
|
+
|
|
1630
|
+
if charging_manager._pricing_info is None:
|
|
1631
|
+
await _gated_push(push_fn, rows_in)
|
|
1632
|
+
return ChargeResult(chargeable_within_limit={}, charged_count=0, event_charge_limit_reached=False)
|
|
1633
|
+
|
|
1634
|
+
per_row: Dict[str, int] = {
|
|
1635
|
+
name: n for name, n in charges.items()
|
|
1636
|
+
if (charging_manager._calculate_event_price(name) or Decimal('0')) > 0
|
|
1637
|
+
}
|
|
1638
|
+
if is_default_dataset:
|
|
1639
|
+
default_event = charging_manager._config.get('default_dataset_item_event_name') or \
|
|
1640
|
+
resolve_default_dataset_item_event_name(charging_manager._per_event_prices)
|
|
1641
|
+
if default_event and default_event not in per_row and \
|
|
1642
|
+
(charging_manager._calculate_event_price(default_event) or Decimal('0')) > 0:
|
|
1643
|
+
per_row[default_event] = 1
|
|
1644
|
+
if not per_row:
|
|
1645
|
+
# Every listed event is free: a plain (unpaid) push.
|
|
1646
|
+
return await _unpaid_push_locked(charging_manager, rows_in, is_default_dataset, push_fn)
|
|
1647
|
+
if not voids_confirmed and any(not is_default_dataset_item_event(n) for n in per_row):
|
|
1648
|
+
raise RuntimeError('An earlier charge for a failed dataset push could not be voided yet; retry the push')
|
|
1649
|
+
|
|
1650
|
+
# Rows the budget can pay for in full (no one-event overdraw: a row is all or nothing).
|
|
1651
|
+
row_cost = sum(
|
|
1652
|
+
(charging_manager._calculate_event_price(n) or Decimal('0')) * Decimal(c) for n, c in per_row.items()
|
|
1653
|
+
)
|
|
1654
|
+
rows = len(rows_in)
|
|
1655
|
+
if charging_manager._max_total_charge_usd is not None and row_cost > 0:
|
|
1656
|
+
remaining = charging_manager._max_total_charge_usd - charging_manager._total_charged_usd
|
|
1657
|
+
rows = min(rows, max(0, int(remaining / row_cost)))
|
|
1658
|
+
|
|
1659
|
+
ops: List[tuple] = [] # (event, charged, op_id, uncertain)
|
|
1660
|
+
|
|
1661
|
+
async def undo(entries: List[tuple]) -> None:
|
|
1662
|
+
for name, charged, op_id, uncertain in entries:
|
|
1663
|
+
if charged <= 0:
|
|
1664
|
+
continue
|
|
1665
|
+
if op_id is None:
|
|
1666
|
+
charging_manager._unwind_reservation(name, charged)
|
|
1667
|
+
elif uncertain:
|
|
1668
|
+
await charging_manager._cancel_uncertain_charge(name, op_id, charged)
|
|
1669
|
+
else:
|
|
1670
|
+
await charging_manager._void_charge(name, op_id, charged)
|
|
1671
|
+
|
|
1672
|
+
for _attempt in range(4):
|
|
1673
|
+
if rows <= 0:
|
|
1674
|
+
break
|
|
1675
|
+
ops = []
|
|
1676
|
+
short_to: Optional[int] = None
|
|
1677
|
+
for name, n in sorted(per_row.items(), key=lambda kv: (is_default_dataset_item_event(kv[0]), kv[0])):
|
|
1678
|
+
want = n * rows
|
|
1679
|
+
charging_manager._take_last_accepted_operation_id()
|
|
1680
|
+
try:
|
|
1681
|
+
r = await charging_manager._charge_exclusive(name, want)
|
|
1682
|
+
op_id = charging_manager._take_last_accepted_operation_id()
|
|
1683
|
+
ops.append((name, r.charged_count, op_id, False))
|
|
1684
|
+
got = r.charged_count
|
|
1685
|
+
except ChargeUncertainError as unconfirmed:
|
|
1686
|
+
# Not confirmed: the intent stays pending and is re-posted at exit,
|
|
1687
|
+
# so it counts as accepted (items stored, billed once).
|
|
1688
|
+
ops.append((name, unconfirmed.count, unconfirmed.operation_id, True))
|
|
1689
|
+
got = unconfirmed.count
|
|
1690
|
+
if got < want:
|
|
1691
|
+
short_to = got // n
|
|
1692
|
+
break
|
|
1693
|
+
if short_to is None:
|
|
1694
|
+
break
|
|
1695
|
+
# Some event came back short: void this attempt in full and retry with
|
|
1696
|
+
# the rows that event can pay for (a void restores the budget).
|
|
1697
|
+
await undo(ops)
|
|
1698
|
+
ops = []
|
|
1699
|
+
rows = short_to # got < n * rows, so short_to < rows
|
|
1700
|
+
|
|
1701
|
+
if rows <= 0 or not ops:
|
|
1702
|
+
await undo(ops)
|
|
1703
|
+
charging_manager._mark_charge_limit_hit()
|
|
1704
|
+
if is_default_dataset:
|
|
1705
|
+
charging_manager._note_refused_default_dataset_items(len(rows_in))
|
|
1706
|
+
return ChargeResult(
|
|
1707
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1708
|
+
charged_count=0,
|
|
1709
|
+
event_charge_limit_reached=True,
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
kept = rows_in[:rows]
|
|
1713
|
+
if rows < len(rows_in):
|
|
1714
|
+
charging_manager._mark_charge_limit_hit()
|
|
1715
|
+
if is_default_dataset:
|
|
1716
|
+
charging_manager._note_refused_default_dataset_items(len(rows_in) - rows)
|
|
1717
|
+
try:
|
|
1718
|
+
await _gated_push(push_fn, kept)
|
|
1719
|
+
except Exception as push_err:
|
|
1720
|
+
logger.error('Dataset push failed after charges accepted (rows=%s): %s; voiding the charges', rows, push_err)
|
|
1721
|
+
if _push_certainly_failed(push_err):
|
|
1722
|
+
await undo(ops)
|
|
1723
|
+
raise
|
|
1724
|
+
return ChargeResult(
|
|
1725
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1726
|
+
charged_count=rows,
|
|
1727
|
+
event_charge_limit_reached=rows < len(rows_in) or charging_manager.is_charge_limit_reached(),
|
|
1728
|
+
)
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
async def _unpaid_push_locked(
|
|
1732
|
+
charging_manager: ChargingManager, rows: List[Dict[str, Any]], is_default_dataset: bool, push_fn: Any
|
|
1733
|
+
) -> ChargeResult:
|
|
1734
|
+
"""A push whose rows carry no priced event (caller holds the operation lock)."""
|
|
1735
|
+
past_limit = charging_manager.is_charge_limit_reached()
|
|
1736
|
+
allowed = charging_manager._take_unpaid_rows(len(rows)) if is_default_dataset else len(rows)
|
|
1737
|
+
if allowed < len(rows):
|
|
1738
|
+
charging_manager._note_refused_default_dataset_items(len(rows) - allowed)
|
|
1739
|
+
if allowed > 0:
|
|
1740
|
+
await _gated_push(push_fn, rows[:allowed])
|
|
1741
|
+
return ChargeResult(
|
|
1742
|
+
chargeable_within_limit=charging_manager.compute_chargeable(),
|
|
1743
|
+
charged_count=0,
|
|
1744
|
+
event_charge_limit_reached=past_limit,
|
|
1745
|
+
)
|
|
1746
|
+
|
|
1747
|
+
|
|
1588
1748
|
def _push_is_paid_per_item(
|
|
1589
1749
|
charging_manager: ChargingManager, event_name: Optional[str], resolved_event: Optional[str]
|
|
1590
1750
|
) -> bool:
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
scrapely/__init__.py,sha256=
|
|
1
|
+
scrapely/__init__.py,sha256=EHGVITjobhdZu3nhkmBmECrlftUMCsnDyLAIxdnZ_7A,5746
|
|
2
2
|
scrapely/_browser_locations.py,sha256=8QVVnkms2bDn3_LDYgYDR59MvFx8757l_JYnZ7xb3oI,145269
|
|
3
|
-
scrapely/actor.py,sha256=
|
|
3
|
+
scrapely/actor.py,sha256=Lsb4_14RCGQy-EWACnqijo7Hz5xvPKSDZcu4B8_ZrHc,81685
|
|
4
4
|
scrapely/browser_configuration.py,sha256=jAZapNSHLFW05B96df4wNBtcyiiU9DA3ml0yTL1FwfA,5770
|
|
5
5
|
scrapely/cdp_retry.py,sha256=hdTJEAIKhApM7IOk1DrQEl8NoflSJSmqd4RbNJ2IsgI,8540
|
|
6
|
-
scrapely/charging.py,sha256=
|
|
6
|
+
scrapely/charging.py,sha256=maRgijO8vM8mCb_S9lnvF99zNR_G8sB3LsubU1r6t6M,79326
|
|
7
7
|
scrapely/charging_client.py,sha256=-4q-Ww7gu9EZXpM9QIn1Vd4vwAXmgL_WEKqaQIx3ViA,3961
|
|
8
8
|
scrapely/configuration.py,sha256=Of-lKm7hoPGUQO8d08kV7fAy2co9QTfb8Of_8ds5PSA,17028
|
|
9
9
|
scrapely/errors.py,sha256=DCAlVvE3jToZuPEG2HVDL7i8D8hLboomQ5JpSOcsXoQ,4107
|
|
@@ -39,10 +39,10 @@ scrapely/storage/_file_system/_key_value_store_client.py,sha256=4Ipgbh4qEqAkwQJ9
|
|
|
39
39
|
scrapely/storage/_file_system/_storage_client.py,sha256=aaGJ4P0QAtIgEMl6yHpuobeWFD5M4rMC5HwhOOvuGuo,1794
|
|
40
40
|
scrapely/storage_clients/__init__.py,sha256=cs1YX7JV2_GKBXeiiJgkQFali08b5_y_jH6Yw7iROAA,656
|
|
41
41
|
scrapely/storages/__init__.py,sha256=4PAELhJNZ3exJ7Uouicn-bCQ58NafKjhLBixQ8ATQc4,173
|
|
42
|
-
scrapely_python-0.1.
|
|
43
|
-
scrapely_python-0.1.
|
|
44
|
-
scrapely_python-0.1.
|
|
45
|
-
scrapely_python-0.1.
|
|
46
|
-
scrapely_python-0.1.
|
|
47
|
-
scrapely_python-0.1.
|
|
48
|
-
scrapely_python-0.1.
|
|
42
|
+
scrapely_python-0.1.18.dist-info/licenses/LICENSE,sha256=pQGY0XwjIYh-a2HfawxHq1en53bXkFkDj2Q70jKWHr4,1088
|
|
43
|
+
scrapely_python-0.1.18.dist-info/licenses/LICENSE-APACHE,sha256=N8m0FzMq8i4UbTgXZvKEZJSD-27OF8pWZYS_atz4kTU,11355
|
|
44
|
+
scrapely_python-0.1.18.dist-info/licenses/NOTICE,sha256=DEsNrw6UdSIqdYYqMWVFgJnyt6jUivTD7iFU6YM_e1A,1122
|
|
45
|
+
scrapely_python-0.1.18.dist-info/METADATA,sha256=3znh1pGrKvP0jNRAftMgiGSKuBG1c3g8UWJtgMYI95g,3487
|
|
46
|
+
scrapely_python-0.1.18.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
47
|
+
scrapely_python-0.1.18.dist-info/top_level.txt,sha256=T4B9UMrs4q99QSAFyD1zdXhl40KnLhBKqrDkPhOTsmY,9
|
|
48
|
+
scrapely_python-0.1.18.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
{scrapely_python-0.1.17.dist-info → scrapely_python-0.1.18.dist-info}/licenses/LICENSE-APACHE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|