scrapely-python 0.1.15__tar.gz → 0.1.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {scrapely_python-0.1.15/src/scrapely_python.egg-info → scrapely_python-0.1.17}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/actor.py +28 -0
  5. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/charging.py +61 -11
  6. scrapely_python-0.1.17/src/scrapely/solver.py +875 -0
  7. {scrapely_python-0.1.15 → scrapely_python-0.1.17/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  8. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely_python.egg-info/SOURCES.txt +2 -0
  9. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_charge_limit_guard.py +111 -0
  10. scrapely_python-0.1.17/tests/test_solver.py +327 -0
  11. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/LICENSE +0 -0
  12. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/LICENSE-APACHE +0 -0
  13. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/NOTICE +0 -0
  14. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/README.md +0 -0
  15. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/setup.cfg +0 -0
  16. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/_browser_locations.py +0 -0
  17. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/browser_configuration.py +0 -0
  18. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/cdp_retry.py +0 -0
  19. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/charging_client.py +0 -0
  20. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/configuration.py +0 -0
  21. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/crypto/__init__.py +0 -0
  22. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/crypto/_secrets.py +0 -0
  23. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/errors.py +0 -0
  24. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/events/__init__.py +0 -0
  25. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/events/_types.py +0 -0
  26. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/events/event_manager.py +0 -0
  27. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/log.py +0 -0
  28. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/patch_browser_pool.py +0 -0
  29. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/pricing_types.py +0 -0
  30. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/proxy_configuration.py +0 -0
  31. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/py.typed +0 -0
  32. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/request_loaders/__init__.py +0 -0
  33. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  34. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/__init__.py +0 -0
  35. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/_file_system/__init__.py +0 -0
  36. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  37. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  38. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/_metadata.py +0 -0
  39. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/api_client.py +0 -0
  40. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/client.py +0 -0
  41. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/patch_storage.py +0 -0
  42. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  43. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  44. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  45. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  46. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  47. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/types.py +0 -0
  48. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage/utils.py +0 -0
  49. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storage_clients/__init__.py +0 -0
  50. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/storages/__init__.py +0 -0
  51. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/types.py +0 -0
  52. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely/webhook.py +0 -0
  53. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  54. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely_python.egg-info/requires.txt +0 -0
  55. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/src/scrapely_python.egg-info/top_level.txt +0 -0
  56. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_api_client_retries.py +0 -0
  57. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_apify_parity.py +0 -0
  58. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_browser_configuration.py +0 -0
  59. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_cdp_retry.py +0 -0
  60. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_charge_retries.py +0 -0
  61. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_charging_parity.py +0 -0
  62. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_charging_pending.py +0 -0
  63. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_input_proxy_aliases.py +0 -0
  64. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_parity.py +0 -0
  65. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_patch_browser_pool.py +0 -0
  66. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_proxy_configuration.py +0 -0
  67. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_push_idempotency.py +0 -0
  68. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_run_control.py +0 -0
  69. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_secrets.py +0 -0
  70. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_storage.py +0 -0
  71. {scrapely_python-0.1.15 → scrapely_python-0.1.17}/tests/test_terminal_status_message.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.15
3
+ Version: 0.1.17
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
6
  License-Expression: MIT AND Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.15"
7
+ version = "0.1.17"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
10
  license = "MIT AND Apache-2.0"
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.15'
135
+ __version__ = '0.1.17'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -1080,6 +1080,34 @@ class Actor(metaclass=_ActorMeta):
1080
1080
  status_message=data.get('statusMessage'),
1081
1081
  )
1082
1082
 
1083
+ @classmethod
1084
+ async def solver_status(cls, vendor: str) -> Dict[str, Any]:
1085
+ """Anti-bot solver status for a vendor (free, cached for 15 s, never raises).
1086
+
1087
+ ``usable`` False means the vendor cannot be solved right now: take your
1088
+ fallback (for example the hosted browser), or fail the items that need
1089
+ a solve at once and call ``set_solver_degraded_status()``. See
1090
+ ``scrapely.solver``.
1091
+ """
1092
+ from .solver import solver_status_async
1093
+
1094
+ return await solver_status_async(vendor)
1095
+
1096
+ @classmethod
1097
+ async def set_solver_degraded_status(cls, skipped: Optional[int] = None, total: Optional[int] = None) -> None:
1098
+ """Set the standard status message for a run whose site protection cannot be solved right now.
1099
+
1100
+ "Site protection temporarily unavailable, results may be incomplete",
1101
+ with "(N of M items skipped)" when both counts are given. Never raises:
1102
+ the run goes on.
1103
+ """
1104
+ from .solver import degraded_status_message
1105
+
1106
+ try:
1107
+ await cls.set_status_message(degraded_status_message(skipped, total))
1108
+ except Exception as exc: # noqa: BLE001 - a status message must never stop the run
1109
+ logger.warning(f'Could not set the solver status message: {exc}')
1110
+
1083
1111
  @classmethod
1084
1112
  async def _put_run_status_message(
1085
1113
  cls, message: str, *, is_terminal: bool
@@ -270,6 +270,13 @@ class ChargingManager:
270
270
  self._charge_limit_hit = False
271
271
  # Default-dataset rows refused after the limit (one warning, then a count).
272
272
  self._refused_default_dataset_items = 0
273
+ # Prepaid-row balance: priced events charged through charge() minus rows
274
+ # pushed to the default dataset without a per-row charge. An Actor that
275
+ # charges first and then pushes plainly (charge('product'); push_data(row))
276
+ # has a positive balance after the charge, so the row it already paid for
277
+ # is still saved when that charge used up the spending limit. Push-then-
278
+ # charge Actors never go positive, so they get nothing extra for free.
279
+ self._prepaid_row_balance = 0
273
280
 
274
281
  def close(self) -> None:
275
282
  """Mark the manager closed; subsequent charge/push operations are rejected."""
@@ -323,8 +330,40 @@ class ChargingManager:
323
330
  )
324
331
  self._refused_default_dataset_items += count
325
332
 
333
+ def _note_prepaid_charge(self, event_name: str, charged_count: int) -> None:
334
+ if (
335
+ charged_count > 0
336
+ and not is_default_dataset_item_event(event_name)
337
+ and self._per_event_prices.get(event_name, 0) > 0
338
+ ):
339
+ self._prepaid_row_balance += charged_count
340
+
341
+ def _take_unpaid_rows(self, item_count: int) -> int:
342
+ """Account for ``item_count`` rows pushed without a per-row charge.
343
+
344
+ Returns how many of them may be stored: all of them while the limit is
345
+ not reached, afterwards only the rows already paid for by earlier
346
+ charges (the positive prepaid balance).
347
+ """
348
+ if item_count <= 0:
349
+ return 0
350
+ if not self.is_charge_limit_reached():
351
+ self._prepaid_row_balance -= item_count
352
+ return item_count
353
+ allowed = max(0, min(item_count, self._prepaid_row_balance))
354
+ self._prepaid_row_balance -= allowed
355
+ return allowed
356
+
326
357
  def _refuse_default_dataset_push(self, item_count: int) -> bool:
327
- if item_count <= 0 or not self.is_charge_limit_reached():
358
+ if item_count <= 0 or self._pricing_info is None:
359
+ return False
360
+ if not self.is_charge_limit_reached():
361
+ self._take_unpaid_rows(item_count)
362
+ return False
363
+ # All-or-nothing here (storage clients cannot store a slice): store the
364
+ # batch only when earlier charges paid for every row of it.
365
+ if self._prepaid_row_balance >= item_count:
366
+ self._prepaid_row_balance -= item_count
328
367
  return False
329
368
  self._note_refused_default_dataset_items(item_count)
330
369
  return True
@@ -851,7 +890,9 @@ class ChargingManager:
851
890
  async def charge(self, event_name: str, count: int = 1) -> ChargeResult:
852
891
  """Atomically reserve and submit one logical charge operation."""
853
892
  async with self._operation_lock:
854
- return await self._charge_exclusive(event_name, count)
893
+ result = await self._charge_exclusive(event_name, count)
894
+ self._note_prepaid_charge(event_name, result.charged_count)
895
+ return result
855
896
 
856
897
  async def _charge_exclusive(self, event_name: str, count: int = 1) -> ChargeResult:
857
898
  if self._closed:
@@ -1327,19 +1368,27 @@ async def push_data_and_charge(
1327
1368
  # default-dataset push that does not pay per row (no event, or only
1328
1369
  # free events) stores nothing. Paid pushes go on below, where the
1329
1370
  # budget cap stores only the rows that are actually charged.
1371
+ # Rows the Actor already paid for with an earlier charge() (charge first,
1372
+ # then a plain push) are still stored: see ChargingManager._take_unpaid_rows.
1373
+ # The result still reports the limit, so the Actor stops after them.
1374
+ unpaid_past_limit = False
1330
1375
  if (
1331
1376
  is_ppe
1332
1377
  and is_default_dataset
1333
1378
  and items
1334
- and charging_manager.is_charge_limit_reached()
1335
1379
  and not _push_is_paid_per_item(charging_manager, event_name, resolved_event)
1336
1380
  ):
1337
- charging_manager._note_refused_default_dataset_items(len(items))
1338
- return ChargeResult(
1339
- chargeable_within_limit=charging_manager.compute_chargeable(),
1340
- charged_count=0,
1341
- event_charge_limit_reached=True,
1342
- )
1381
+ unpaid_past_limit = charging_manager.is_charge_limit_reached()
1382
+ allowed = charging_manager._take_unpaid_rows(len(items))
1383
+ if allowed < len(items):
1384
+ charging_manager._note_refused_default_dataset_items(len(items) - allowed)
1385
+ if allowed == 0:
1386
+ return ChargeResult(
1387
+ chargeable_within_limit=charging_manager.compute_chargeable(),
1388
+ charged_count=0,
1389
+ event_charge_limit_reached=True,
1390
+ )
1391
+ items = items[:allowed]
1343
1392
 
1344
1393
  limited_count = len(items)
1345
1394
  if is_ppe and resolved_event:
@@ -1522,7 +1571,8 @@ async def push_data_and_charge(
1522
1571
  event_charge_limit_reached=any(
1523
1572
  result.event_charge_limit_reached for result in results
1524
1573
  )
1525
- or accepted_for_items < len(limited_items),
1574
+ or accepted_for_items < len(limited_items)
1575
+ or unpaid_past_limit,
1526
1576
  )
1527
1577
 
1528
1578
 
@@ -1531,7 +1581,7 @@ async def push_data_and_charge(
1531
1581
  return ChargeResult(
1532
1582
  chargeable_within_limit=charging_manager.compute_chargeable(),
1533
1583
  charged_count=0,
1534
- event_charge_limit_reached=False,
1584
+ event_charge_limit_reached=unpaid_past_limit,
1535
1585
  )
1536
1586
 
1537
1587