scrapely-python 0.1.1__tar.gz → 0.1.3__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 (58) hide show
  1. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/PKG-INFO +3 -4
  2. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/pyproject.toml +4 -5
  3. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/actor.py +9 -8
  5. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/charging.py +132 -1
  6. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/proxy_configuration.py +38 -9
  7. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely_python.egg-info/PKG-INFO +3 -4
  8. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely_python.egg-info/requires.txt +1 -1
  9. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_charging_pending.py +202 -0
  10. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_proxy_configuration.py +171 -2
  11. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/README.md +0 -0
  12. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/setup.cfg +0 -0
  13. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/_browser_locations.py +0 -0
  14. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/browser_configuration.py +0 -0
  15. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/charging_client.py +0 -0
  16. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/configuration.py +0 -0
  17. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/crypto/__init__.py +0 -0
  18. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/crypto/_secrets.py +0 -0
  19. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/errors.py +0 -0
  20. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/events/__init__.py +0 -0
  21. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/events/_types.py +0 -0
  22. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/events/event_manager.py +0 -0
  23. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/log.py +0 -0
  24. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/patch_browser_pool.py +0 -0
  25. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/pricing_types.py +0 -0
  26. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/py.typed +0 -0
  27. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/request_loaders/__init__.py +0 -0
  28. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  29. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/__init__.py +0 -0
  30. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/_file_system/__init__.py +0 -0
  31. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  32. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  33. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/_metadata.py +0 -0
  34. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/api_client.py +0 -0
  35. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/client.py +0 -0
  36. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/patch_storage.py +0 -0
  37. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  38. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  39. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  40. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  41. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  42. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/types.py +0 -0
  43. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage/utils.py +0 -0
  44. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storage_clients/__init__.py +0 -0
  45. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/storages/__init__.py +0 -0
  46. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/types.py +0 -0
  47. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely/webhook.py +0 -0
  48. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  49. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  50. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/src/scrapely_python.egg-info/top_level.txt +0 -0
  51. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_apify_parity.py +0 -0
  52. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_browser_configuration.py +0 -0
  53. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_charging_parity.py +0 -0
  54. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_parity.py +0 -0
  55. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_patch_browser_pool.py +0 -0
  56. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_run_control.py +0 -0
  57. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_secrets.py +0 -0
  58. {scrapely_python-0.1.1 → scrapely_python-0.1.3}/tests/test_storage.py +0 -0
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://scrape.ly
8
8
  Project-URL: Documentation, https://docs.scrape.ly
9
9
  Project-URL: Repository, https://github.com/SandeepDev1/scrapely
10
10
  Keywords: scraping,actor,serverless,apify,scrapely
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Operating System :: OS Independent
15
14
  Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Programming Language :: Python :: 3.11
@@ -22,7 +21,7 @@ Description-Content-Type: text/markdown
22
21
  Requires-Dist: httpx>=0.25.0
23
22
  Requires-Dist: crawlee<2.0.0,>=1.8.0
24
23
  Requires-Dist: websockets>=12.0
25
- Requires-Dist: scrapely-client-python<2,>=1.0.0
24
+ Requires-Dist: scrapely-client-python<2,>=1.0.2
26
25
  Requires-Dist: pydantic>=2.0.0
27
26
  Requires-Dist: cryptography>=42.0.0
28
27
  Provides-Extra: dev
@@ -1,20 +1,19 @@
1
1
  [build-system]
2
- requires = ["setuptools>=61.0", "wheel"]
2
+ requires = ["setuptools>=77.0", "wheel"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scrapely-python"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
9
9
  readme = "README.md"
10
- license = {text = "MIT"}
10
+ license = "MIT"
11
11
  authors = [
12
12
  {name = "Scrapely", email = "support@scrape.ly"}
13
13
  ]
14
14
  classifiers = [
15
15
  "Development Status :: 3 - Alpha",
16
16
  "Intended Audience :: Developers",
17
- "License :: OSI Approved :: MIT License",
18
17
  "Operating System :: OS Independent",
19
18
  "Programming Language :: Python :: 3",
20
19
  "Programming Language :: Python :: 3.11",
@@ -28,7 +27,7 @@ dependencies = [
28
27
  "httpx>=0.25.0",
29
28
  "crawlee>=1.8.0,<2.0.0",
30
29
  "websockets>=12.0",
31
- "scrapely-client-python>=1.0.0,<2",
30
+ "scrapely-client-python>=1.0.2,<2",
32
31
  "pydantic>=2.0.0",
33
32
  "cryptography>=42.0.0",
34
33
  ]
@@ -132,7 +132,7 @@ from .errors import (
132
132
  raise_for_status,
133
133
  )
134
134
 
135
- __version__ = '0.1.0'
135
+ __version__ = '0.1.3'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -1776,19 +1776,20 @@ class Actor(metaclass=_ActorMeta):
1776
1776
  """
1777
1777
  cls._ensure_initialized('create_proxy_configuration')
1778
1778
 
1779
- from .proxy_configuration import ProxyConfiguration
1779
+ from .proxy_configuration import ProxyConfiguration, _input_proxy_option, _input_use_platform_proxy
1780
1780
 
1781
1781
  # Handle actor_proxy_input (Node SDK normalizeProxyInput/createProxyConfiguration
1782
- # parity): `useApifyProxy: false` explicitly disables the PLATFORM proxy only —
1782
+ # parity): `useScrapelyProxy: false` (or the legacy `useApifyProxy: false`; a
1783
+ # boolean useScrapelyProxy wins) explicitly disables the PLATFORM proxy only —
1783
1784
  # custom proxies (proxyUrls / tiered lists / new_url_function) from the same
1784
1785
  # input must still be used. The standard "Custom proxy URLs" input shape is
1785
- # {useApifyProxy: false, proxyUrls: [...]}; returning None for it would
1786
+ # {useScrapelyProxy: false, proxyUrls: [...]}; returning None for it would
1786
1787
  # silently drop the user's own proxies and crawl on their real IPs. We only
1787
1788
  # bail when no custom proxies are given either. A MISSING key falls back to
1788
1789
  # platform mode when the server injected credentials — input like
1789
- # {apifyProxyGroups: [...]} must not silently disable proxying.
1790
+ # {scrapelyProxyGroups: [...]} must not silently disable proxying.
1790
1791
  if actor_proxy_input is not None:
1791
- if actor_proxy_input.get('useApifyProxy') is False and not (
1792
+ if _input_use_platform_proxy(actor_proxy_input) is False and not (
1792
1793
  proxy_urls
1793
1794
  or new_url_function
1794
1795
  or tiered_proxy_urls
@@ -1798,9 +1799,9 @@ class Actor(metaclass=_ActorMeta):
1798
1799
  or actor_proxy_input.get('tieredProxyConfig')
1799
1800
  ):
1800
1801
  return None
1801
- country_code = country_code or actor_proxy_input.get('apifyProxyCountry')
1802
- subdivision_code = subdivision_code or actor_proxy_input.get('apifyProxySubdivision')
1803
- groups = groups or actor_proxy_input.get('apifyProxyGroups')
1802
+ country_code = country_code or _input_proxy_option(actor_proxy_input, 'Country')
1803
+ subdivision_code = subdivision_code or _input_proxy_option(actor_proxy_input, 'Subdivision')
1804
+ groups = groups or _input_proxy_option(actor_proxy_input, 'Groups')
1804
1805
  proxy_urls = proxy_urls or actor_proxy_input.get('proxyUrls')
1805
1806
  tiered_proxy_urls = tiered_proxy_urls or actor_proxy_input.get('tieredProxyUrls')
1806
1807
  tiered_proxy_config = tiered_proxy_config or actor_proxy_input.get('tieredProxyConfig')
@@ -195,6 +195,11 @@ class ChargingManager:
195
195
  self._pending_charge_keys: Dict[str, Dict[str, Any]] = {}
196
196
  # Optional local charging-log dataset (ACTOR_USE_CHARGING_LOG_DATASET).
197
197
  self._charging_log_dataset: Any = None
198
+ # Idempotency key of the last charge the platform accepted
199
+ # (push_data_and_charge voids it when the push then fails).
200
+ self._last_accepted_operation_id: Optional[str] = None
201
+ # Voids not yet confirmed by the platform; retried before the next push and at exit.
202
+ self._pending_voids: List[tuple[str, str, int]] = []
198
203
 
199
204
  def close(self) -> None:
200
205
  """Mark the manager closed; subsequent charge/push operations are rejected."""
@@ -429,6 +434,50 @@ class ChargingManager:
429
434
  self._total_charged_usd - event_price * Decimal(count),
430
435
  )
431
436
 
437
+ def _take_last_accepted_operation_id(self) -> Optional[str]:
438
+ op_id = self._last_accepted_operation_id
439
+ self._last_accepted_operation_id = None
440
+ return op_id
441
+
442
+ async def _void_charge(self, event_name: str, operation_id: str, reserved: int) -> bool:
443
+ """Reverse an accepted charge whose items were never stored, then unwind locally.
444
+
445
+ POST /v2/actor-runs/{run}/charge/void with the charge's Idempotency-Key.
446
+ The local reservation is released only once the platform confirms.
447
+ """
448
+ if self._client is None or not self._is_at_home or not self._actor_run_id:
449
+ return False
450
+ for attempt in range(self._POST_ATTEMPTS):
451
+ try:
452
+ await self._client.post(
453
+ f'/v2/actor-runs/{self._actor_run_id}/charge/void',
454
+ json_data={},
455
+ headers={'Idempotency-Key': operation_id},
456
+ )
457
+ self._unwind_reservation(event_name, reserved)
458
+ return True
459
+ except Exception as err: # noqa: BLE001
460
+ status = _http_status_of(err)
461
+ # 404: nothing to void; 409: already settled; 4xx auth: permanent.
462
+ if status is not None and 400 <= status < 500:
463
+ return False
464
+ if attempt + 1 < self._POST_ATTEMPTS:
465
+ await asyncio.sleep(0.25 * (attempt + 1))
466
+ # Outcome unknown: keep the reservation and retry before the next push / at exit.
467
+ self._pending_voids.append((event_name, operation_id, reserved))
468
+ logger.error('Void of charge %s (%r) not confirmed yet; will retry', operation_id, event_name)
469
+ return False
470
+
471
+ async def _retry_pending_voids(self) -> bool:
472
+ """Retry unconfirmed voids (caller holds the operation lock).
473
+
474
+ Returns False while any earlier void is still unconfirmed.
475
+ """
476
+ pending, self._pending_voids = self._pending_voids, []
477
+ for event_name, operation_id, reserved in pending:
478
+ await self._void_charge(event_name, operation_id, reserved)
479
+ return not self._pending_voids
480
+
432
481
  def _quarantine_stale_pending(self) -> None:
433
482
  """Remove intents older than the safe replay window (fail closed, no replay).
434
483
 
@@ -544,6 +593,7 @@ class ChargingManager:
544
593
  """Caller must hold _operation_lock."""
545
594
  if not self._is_at_home or self._client is None or not self._actor_run_id:
546
595
  return 0
596
+ await self._retry_pending_voids()
547
597
 
548
598
  import time
549
599
 
@@ -731,6 +781,8 @@ class ChargingManager:
731
781
  )
732
782
  if accepted is not None:
733
783
  self._drop_pending(idempotency_key)
784
+ if accepted > 0:
785
+ self._last_accepted_operation_id = idempotency_key
734
786
  if accepted < chargeable_count:
735
787
  self._unwind_reservation(event_name, chargeable_count - accepted)
736
788
  chargeable_count = accepted
@@ -1032,6 +1084,9 @@ async def push_data_and_charge(
1032
1084
  async with charging_manager._operation_lock:
1033
1085
  if charging_manager._closed:
1034
1086
  raise RuntimeError('ChargingManager is closed (actor is shutting down); charge rejected')
1087
+ # A void that did not go through for an earlier failed push must land
1088
+ # before this push charges again, or one unsaved result is billed twice.
1089
+ voids_confirmed = await charging_manager._retry_pending_voids()
1035
1090
  # Non-PPE actors push everything without charging (JS calculatePushDataLimits
1036
1091
  # parity: `if (!this.isPayPerEvent) return { limitedItems, eventsToCharge: {} }`).
1037
1092
  is_ppe = charging_manager._pricing_info is not None
@@ -1065,6 +1120,29 @@ async def push_data_and_charge(
1065
1120
  event_charge_limit_reached=len(items) > 0 and limited_count == 0,
1066
1121
  )
1067
1122
 
1123
+ # Every platform event this push would charge counts: the explicit event
1124
+ # and, on a default-dataset push, the configured default event. The
1125
+ # synthetic default-dataset event (local bookkeeping) and unpriced
1126
+ # events (never posted) do not.
1127
+ charged_names: List[str] = []
1128
+ if event_name:
1129
+ charged_names.append(event_name)
1130
+ if is_default_dataset:
1131
+ configured_default = charging_manager._config.get('default_dataset_item_event_name')
1132
+ if configured_default:
1133
+ charged_names.append(configured_default)
1134
+ if (
1135
+ not voids_confirmed
1136
+ and is_ppe
1137
+ and any(
1138
+ not is_default_dataset_item_event(name) and name in charging_manager._per_event_prices
1139
+ for name in charged_names
1140
+ )
1141
+ ):
1142
+ raise RuntimeError(
1143
+ 'An earlier charge for a failed dataset push could not be voided yet; retry the push'
1144
+ )
1145
+
1068
1146
  # Charge BEFORE persisting so a server cap/reject cannot leave unbilled
1069
1147
  # items in the dataset. Operation lock is held across both steps.
1070
1148
  if is_ppe and resolved_event:
@@ -1079,15 +1157,20 @@ async def push_data_and_charge(
1079
1157
 
1080
1158
  # Synthetic storage event first (local-only); then explicit billable event.
1081
1159
  reserved_in_op: list[tuple[str, int]] = []
1160
+ operation_ids: list[Optional[str]] = []
1082
1161
  try:
1083
1162
  if default_event:
1163
+ charging_manager._take_last_accepted_operation_id()
1084
1164
  r = await charging_manager._charge_exclusive(default_event, len(limited_items))
1085
1165
  results.append(r)
1086
1166
  reserved_in_op.append((default_event, r.charged_count))
1167
+ operation_ids.append(charging_manager._take_last_accepted_operation_id())
1087
1168
  if event_name and event_name != default_event:
1169
+ charging_manager._take_last_accepted_operation_id()
1088
1170
  r = await charging_manager._charge_exclusive(event_name, len(limited_items))
1089
1171
  results.append(r)
1090
1172
  reserved_in_op.append((event_name, r.charged_count))
1173
+ operation_ids.append(charging_manager._take_last_accepted_operation_id())
1091
1174
  except Exception:
1092
1175
  for name, reserved in reserved_in_op:
1093
1176
  if reserved > 0:
@@ -1118,11 +1201,27 @@ async def push_data_and_charge(
1118
1201
  try:
1119
1202
  await push_fn(limited_items[:accepted_for_items])
1120
1203
  except Exception as push_err:
1204
+ # A result is billed only if it was saved: void this
1205
+ # operation's platform charges and unwind local
1206
+ # reservations, then let the caller retry the push.
1121
1207
  logger.error(
1122
- 'Dataset push failed after charge accepted (items=%s): %s',
1208
+ 'Dataset push failed after charge accepted (items=%s): %s; voiding the charge',
1123
1209
  accepted_for_items,
1124
1210
  push_err,
1125
1211
  )
1212
+ # Only when the push certainly stored nothing (the storage API
1213
+ # answered with an error, or the request was never sent). A
1214
+ # timeout after sending may have stored the items.
1215
+ if not _push_certainly_failed(push_err):
1216
+ raise
1217
+ for (name, _), op_id in zip(reserved_in_op, operation_ids):
1218
+ kept = min(accepted_for_items, next(r.charged_count for n, r in charged_events if n == name))
1219
+ if kept <= 0:
1220
+ continue
1221
+ if op_id:
1222
+ await charging_manager._void_charge(name, op_id, kept)
1223
+ elif is_default_dataset_item_event(name):
1224
+ charging_manager._unwind_reservation(name, kept)
1126
1225
  raise
1127
1226
  return ChargeResult(
1128
1227
  chargeable_within_limit=charging_manager.compute_chargeable(),
@@ -1141,3 +1240,35 @@ async def push_data_and_charge(
1141
1240
  charged_count=0,
1142
1241
  event_charge_limit_reached=False,
1143
1242
  )
1243
+
1244
+
1245
+ def _http_status_of(err: BaseException) -> Optional[int]:
1246
+ status = getattr(err, 'status_code', None)
1247
+ if status is None:
1248
+ response = getattr(err, 'response', None)
1249
+ status = getattr(response, 'status_code', None) if response is not None else None
1250
+ try:
1251
+ return int(status) if status is not None else None
1252
+ except (TypeError, ValueError):
1253
+ return None
1254
+
1255
+
1256
+ def _push_certainly_failed(err: BaseException) -> bool:
1257
+ """True when a dataset push certainly stored nothing.
1258
+
1259
+ The storage API answered with an error status, or the connection was never
1260
+ made. Timeouts and resets after sending are not proof: the write may have landed.
1261
+ """
1262
+ import httpx
1263
+
1264
+ e: Optional[BaseException] = err
1265
+ for _ in range(4):
1266
+ if e is None:
1267
+ break
1268
+ status = _http_status_of(e)
1269
+ if status is not None:
1270
+ return status >= 400
1271
+ if isinstance(e, (httpx.ConnectError, ConnectionRefusedError)):
1272
+ return True
1273
+ e = e.__cause__ or e.__context__
1274
+ return False
@@ -108,6 +108,25 @@ def _sanitize_fragment(value: str) -> str:
108
108
  return re.sub(r'[:@/?#\s]', '', value)
109
109
 
110
110
 
111
+ def _input_use_platform_proxy(actor_proxy_input: Dict[str, Any]) -> Optional[bool]:
112
+ """The proxy input field's platform-proxy switch, or None when it has none.
113
+
114
+ `useScrapelyProxy` wins when it is a boolean; otherwise the legacy
115
+ `useApifyProxy` key of existing inputs and ported actors decides.
116
+ """
117
+ for key in ('useScrapelyProxy', 'useApifyProxy'):
118
+ value = actor_proxy_input.get(key)
119
+ if isinstance(value, bool):
120
+ return value
121
+ return None
122
+
123
+
124
+ def _input_proxy_option(actor_proxy_input: Dict[str, Any], name: str) -> Any:
125
+ """`scrapelyProxy<name>` of the proxy input field when it is set, else the legacy `apifyProxy<name>`."""
126
+ value = actor_proxy_input.get(f'scrapelyProxy{name}')
127
+ return value if value is not None else actor_proxy_input.get(f'apifyProxy{name}')
128
+
129
+
111
130
  def _platform_env() -> Optional[Dict[str, Any]]:
112
131
  """Read platform proxy credentials from the environment (or None)."""
113
132
  hostname = os.environ.get('SCRAPELY_PROXY_HOSTNAME') or os.environ.get('APIFY_PROXY_HOSTNAME')
@@ -213,10 +232,10 @@ class ProxyConfiguration:
213
232
 
214
233
  # If actor_proxy_input is provided, extract settings from it
215
234
  if actor_proxy_input:
216
- if actor_proxy_input.get('useApifyProxy', False):
217
- self._groups = actor_proxy_input.get('apifyProxyGroups', []) or []
218
- self._country_code = actor_proxy_input.get('apifyProxyCountry')
219
- self._subdivision_code = actor_proxy_input.get('apifyProxySubdivision')
235
+ if _input_use_platform_proxy(actor_proxy_input):
236
+ self._groups = _input_proxy_option(actor_proxy_input, 'Groups') or []
237
+ self._country_code = _input_proxy_option(actor_proxy_input, 'Country')
238
+ self._subdivision_code = _input_proxy_option(actor_proxy_input, 'Subdivision')
220
239
  else:
221
240
  self._proxy_urls = actor_proxy_input.get('proxyUrls', []) or []
222
241
  # Tiered lists may also arrive through actor input (future-proof
@@ -291,7 +310,7 @@ class ProxyConfiguration:
291
310
  )
292
311
  if has_real_custom and (self._groups or self._country_code or self._subdivision_code or password):
293
312
  raise ValueError(
294
- 'Cannot combine custom proxies with Apify Proxy! It is not allowed to set '
313
+ 'Cannot combine custom proxies with Scrapely Proxy! It is not allowed to set '
295
314
  '"proxy_urls" or "new_url_function" combined with "groups", "country_code" '
296
315
  'or "subdivision_code".'
297
316
  )
@@ -480,7 +499,8 @@ class ProxyConfiguration:
480
499
  Sends ``GET {status_url}/?format=json`` *through the platform gateway``
481
500
  so the proxy handshake itself authenticates the run's credentials.
482
501
  Mirrors the JS SDK: 4s timeout, 2 attempts, returns ``None`` when the
483
- endpoint stays unreachable. Never raises.
502
+ endpoint stays unreachable. Never raises. ``fromPayload`` is True when
503
+ the gateway answered with its own status JSON rather than an HTTP error.
484
504
  """
485
505
  import urllib.error
486
506
  import urllib.request
@@ -514,6 +534,7 @@ class ProxyConfiguration:
514
534
  'connected': payload.get('connected') is True,
515
535
  'connectionError': payload.get('connectionError') or '',
516
536
  'isManInTheMiddle': payload.get('isManInTheMiddle') is True,
537
+ 'fromPayload': True,
517
538
  }
518
539
  except urllib.error.HTTPError as e:
519
540
  if e.code == 407:
@@ -524,6 +545,7 @@ class ProxyConfiguration:
524
545
  'SCRAPELY_PROXY_*/APIFY_PROXY_* credentials.'
525
546
  ),
526
547
  'isManInTheMiddle': False,
548
+ 'fromPayload': False,
527
549
  }
528
550
  if 590 <= e.code <= 599:
529
551
  description = PROXY_STATUS_CODE_DESCRIPTIONS.get(e.code, 'upstream error')
@@ -531,6 +553,7 @@ class ProxyConfiguration:
531
553
  'connected': False,
532
554
  'connectionError': f'Platform proxy upstream error (HTTP {e.code}: {description}).',
533
555
  'isManInTheMiddle': False,
556
+ 'fromPayload': False,
534
557
  }
535
558
  # Other HTTP errors behave like connection errors: retry.
536
559
  except Exception: # noqa: BLE001 - retry connection errors (Apify behavior)
@@ -623,6 +646,9 @@ class ProxyConfiguration:
623
646
 
624
647
  Raises:
625
648
  ValueError: If proxy configuration is invalid
649
+ ConnectionError: If the platform proxy reports that this configuration
650
+ cannot be used (for example an unknown proxy group), with the
651
+ proxy's ``connectionError`` as the message (Apify parity)
626
652
  """
627
653
  if self._initialized:
628
654
  return
@@ -639,9 +665,10 @@ class ProxyConfiguration:
639
665
  if self._proxy_urls and self._new_url_function:
640
666
  raise ValueError('Cannot specify both proxy_urls and new_url_function')
641
667
 
642
- # Apify-parity access check (best-effort, never fatal): GET
643
- # {status_url}/?format=json *through the platform gateway* so the proxy
644
- # handshake itself authenticates the run's credentials. Parity with the
668
+ # Apify-parity access check: GET {status_url}/?format=json *through the
669
+ # platform gateway* so the proxy handshake itself authenticates the run's
670
+ # credentials. A "connected": false answer raises like Apify's
671
+ # _check_access; timeouts and HTTP errors only warn. Parity with the
645
672
  # Apify SDKs (usesApifyProxy / uses_apify_proxy) and the JS Scrapely
646
673
  # port (platformEnv): the check only applies when this configuration
647
674
  # actually uses the platform proxy — BYOP-only configurations skip it
@@ -662,6 +689,8 @@ class ProxyConfiguration:
662
689
  )
663
690
  else:
664
691
  self._is_man_in_the_middle = status['isManInTheMiddle']
692
+ if not status['connected'] and status['fromPayload']:
693
+ raise ConnectionError(status['connectionError'])
665
694
  if not status['connected']:
666
695
  logger.warning('Platform proxy access check failed: %s', status['connectionError'])
667
696
  else:
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform
5
5
  Author-email: Scrapely <support@scrape.ly>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://scrape.ly
8
8
  Project-URL: Documentation, https://docs.scrape.ly
9
9
  Project-URL: Repository, https://github.com/SandeepDev1/scrapely
10
10
  Keywords: scraping,actor,serverless,apify,scrapely
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Operating System :: OS Independent
15
14
  Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Programming Language :: Python :: 3.11
@@ -22,7 +21,7 @@ Description-Content-Type: text/markdown
22
21
  Requires-Dist: httpx>=0.25.0
23
22
  Requires-Dist: crawlee<2.0.0,>=1.8.0
24
23
  Requires-Dist: websockets>=12.0
25
- Requires-Dist: scrapely-client-python<2,>=1.0.0
24
+ Requires-Dist: scrapely-client-python<2,>=1.0.2
26
25
  Requires-Dist: pydantic>=2.0.0
27
26
  Requires-Dist: cryptography>=42.0.0
28
27
  Provides-Extra: dev
@@ -1,7 +1,7 @@
1
1
  httpx>=0.25.0
2
2
  crawlee<2.0.0,>=1.8.0
3
3
  websockets>=12.0
4
- scrapely-client-python<2,>=1.0.0
4
+ scrapely-client-python<2,>=1.0.2
5
5
  pydantic>=2.0.0
6
6
  cryptography>=42.0.0
7
7
 
@@ -457,3 +457,205 @@ async def test_dual_event_push_unwinds_synthetic_on_partial_explicit() -> None:
457
457
  assert len(pushed) == 1
458
458
  assert mgr.get_charged_event_count('page-open') == 1
459
459
  assert mgr.get_charged_event_count('apify-default-dataset-item') == 1
460
+
461
+
462
+ @pytest.mark.asyncio
463
+ async def test_push_failure_after_charge_voids_the_charge() -> None:
464
+ """A result is billed only if it was saved: a failed push voids its charge."""
465
+ client = FakeClient()
466
+ mgr = _make_manager(client)
467
+
468
+ class PushHttpError(Exception):
469
+ status_code = 503
470
+
471
+ async def failing_push(_items: Any) -> None:
472
+ raise PushHttpError('503 from dataset push')
473
+
474
+ with pytest.raises(PushHttpError, match='503'):
475
+ await push_data_and_charge(mgr, [{'a': 1}, {'a': 2}], 'page-open', False, failing_push)
476
+
477
+ charge_posts = [p for p in client.posts if p['path'].endswith('/charge')]
478
+ void_posts = [p for p in client.posts if p['path'].endswith('/charge/void')]
479
+ assert len(charge_posts) == 1
480
+ assert len(void_posts) == 1
481
+ assert void_posts[0]['path'] == '/v2/actor-runs/run-test-1/charge/void'
482
+ assert void_posts[0]['headers']['Idempotency-Key'] == charge_posts[0]['headers']['Idempotency-Key']
483
+ assert mgr.get_charged_event_count('page-open') == 0
484
+
485
+ pushed: List[Any] = []
486
+
487
+ async def ok_push(items: Any) -> None:
488
+ pushed.extend(items)
489
+
490
+ await push_data_and_charge(mgr, [{'a': 3}], 'page-open', False, ok_push)
491
+ assert len([p for p in client.posts if p['path'].endswith('/charge/void')]) == 1
492
+ assert mgr.get_charged_event_count('page-open') == 1
493
+ assert pushed == [{'a': 3}]
494
+
495
+
496
+
497
+ class VoidFlakyClient(FakeClient):
498
+ def __init__(self, void_failures: int = 0) -> None:
499
+ super().__init__()
500
+ self.void_failures = void_failures
501
+
502
+ async def post(self, path: str, json_data: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs: Any) -> Dict[str, Any]:
503
+ if path.endswith('/charge/void') and self.void_failures > 0:
504
+ self.void_failures -= 1
505
+ self.posts.append({'path': path + '#failed', 'json': json_data, 'headers': headers or {}})
506
+ raise TimeoutError('void timed out')
507
+ return await super().post(path, json_data=json_data, headers=headers, **kwargs)
508
+
509
+
510
+ @pytest.mark.asyncio
511
+ async def test_push_timeout_after_send_keeps_the_charge() -> None:
512
+ client = FakeClient()
513
+ mgr = _make_manager(client)
514
+
515
+ async def reset_push(_items: Any) -> None:
516
+ raise TimeoutError('read timed out') # may have been stored
517
+
518
+ with pytest.raises(TimeoutError):
519
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, reset_push)
520
+ assert not [p for p in client.posts if p['path'].endswith('/charge/void')]
521
+ assert mgr.get_charged_event_count('page-open') == 1
522
+
523
+
524
+ @pytest.mark.asyncio
525
+ async def test_unconfirmed_void_is_retried_before_next_charge() -> None:
526
+ client = VoidFlakyClient(void_failures=1)
527
+ mgr = _make_manager(client)
528
+
529
+ class PushHttpError(Exception):
530
+ status_code = 502
531
+
532
+ async def failing_push(_items: Any) -> None:
533
+ raise PushHttpError('bad gateway')
534
+
535
+ with pytest.raises(PushHttpError):
536
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
537
+ assert mgr.get_charged_event_count('page-open') == 1 # void unconfirmed: reservation kept
538
+
539
+ async def ok_push(_items: Any) -> None:
540
+ return None
541
+
542
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, ok_push)
543
+ paths = [p['path'].rsplit('/', 1)[-1] for p in client.posts]
544
+ assert paths == ['charge', 'void#failed', 'void', 'charge']
545
+ assert mgr.get_charged_event_count('page-open') == 1
546
+
547
+
548
+
549
+ @pytest.mark.asyncio
550
+ async def test_new_push_refused_while_earlier_void_unconfirmed() -> None:
551
+ client = VoidFlakyClient(void_failures=99)
552
+ mgr = _make_manager(client)
553
+
554
+ class PushHttpError(Exception):
555
+ status_code = 502
556
+
557
+ async def failing_push(_items: Any) -> None:
558
+ raise PushHttpError('bad gateway')
559
+
560
+ with pytest.raises(PushHttpError):
561
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
562
+
563
+ async def ok_push(_items: Any) -> None:
564
+ return None
565
+
566
+ with pytest.raises(RuntimeError, match='could not be voided yet'):
567
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, ok_push)
568
+ assert len([p for p in client.posts if p['path'].endswith('/charge')]) == 1
569
+
570
+
571
+ @pytest.mark.asyncio
572
+ async def test_uncharged_push_not_blocked_by_unconfirmed_void() -> None:
573
+ client = VoidFlakyClient(void_failures=99)
574
+ mgr = _make_manager(client)
575
+
576
+ class PushHttpError(Exception):
577
+ status_code = 502
578
+
579
+ async def failing_push(_items: Any) -> None:
580
+ raise PushHttpError('bad gateway')
581
+
582
+ with pytest.raises(PushHttpError):
583
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
584
+
585
+ pushed: List[Any] = []
586
+
587
+ async def ok_push(items: Any) -> None:
588
+ pushed.append(items)
589
+
590
+ await push_data_and_charge(mgr, [{'b': 1}], None, False, ok_push)
591
+ assert len(pushed) == 1
592
+
593
+
594
+ @pytest.mark.asyncio
595
+ async def test_configured_platform_default_event_is_gated_by_unconfirmed_void() -> None:
596
+ client = VoidFlakyClient(void_failures=99)
597
+ mgr = _make_manager(client)
598
+
599
+ class PushHttpError(Exception):
600
+ status_code = 502
601
+
602
+ async def failing_push(_items: Any) -> None:
603
+ raise PushHttpError('bad gateway')
604
+
605
+ with pytest.raises(PushHttpError):
606
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
607
+
608
+ # A configured default event that is a real platform event must be gated too.
609
+ mgr._config.get = MagicMock(side_effect=lambda key, default=None: 'page-open' if key == 'default_dataset_item_event_name' else None)
610
+
611
+ async def ok_push(_items: Any) -> None:
612
+ return None
613
+
614
+ with pytest.raises(RuntimeError, match='could not be voided yet'):
615
+ await push_data_and_charge(mgr, [{'b': 1}], None, True, ok_push)
616
+
617
+
618
+ @pytest.mark.asyncio
619
+ async def test_unpriced_configured_default_event_not_blocked() -> None:
620
+ client = VoidFlakyClient(void_failures=99)
621
+ mgr = _make_manager(client)
622
+
623
+ class PushHttpError(Exception):
624
+ status_code = 502
625
+
626
+ async def failing_push(_items: Any) -> None:
627
+ raise PushHttpError('bad gateway')
628
+
629
+ with pytest.raises(PushHttpError):
630
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
631
+ mgr._config.get = MagicMock(side_effect=lambda key, default=None: 'not-priced' if key == 'default_dataset_item_event_name' else None)
632
+ pushed: List[Any] = []
633
+
634
+ async def ok_push(items: Any) -> None:
635
+ pushed.append(items)
636
+
637
+ await push_data_and_charge(mgr, [{'b': 1}], None, True, ok_push)
638
+ assert len(pushed) == 1
639
+
640
+
641
+ @pytest.mark.asyncio
642
+ async def test_unpriced_explicit_event_with_priced_default_is_gated() -> None:
643
+ client = VoidFlakyClient(void_failures=99)
644
+ mgr = _make_manager(client)
645
+
646
+ class PushHttpError(Exception):
647
+ status_code = 502
648
+
649
+ async def failing_push(_items: Any) -> None:
650
+ raise PushHttpError('bad gateway')
651
+
652
+ with pytest.raises(PushHttpError):
653
+ await push_data_and_charge(mgr, [{'a': 1}], 'page-open', False, failing_push)
654
+ # Configured default event is priced; the explicit event is not.
655
+ mgr._config.get = MagicMock(side_effect=lambda key, default=None: 'page-open' if key == 'default_dataset_item_event_name' else None)
656
+
657
+ async def ok_push(_items: Any) -> None:
658
+ return None
659
+
660
+ with pytest.raises(RuntimeError, match='could not be voided yet'):
661
+ await push_data_and_charge(mgr, [{'b': 1}], 'not-priced', True, ok_push)
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+
5
7
  import pytest
6
8
 
7
9
  from scrapely.proxy_configuration import ProxyConfiguration
@@ -87,7 +89,7 @@ async def test_sentinel_without_env_raises(monkeypatch: pytest.MonkeyPatch) -> N
87
89
 
88
90
 
89
91
  async def test_real_custom_plus_groups_rejected() -> None:
90
- with pytest.raises(ValueError, match='Cannot combine custom proxies with Apify Proxy'):
92
+ with pytest.raises(ValueError, match='Cannot combine custom proxies with Scrapely Proxy'):
91
93
  ProxyConfiguration(proxy_urls=['http://user:pass@byok.example.com:8080'], groups=['RESIDENTIAL'])
92
94
  # Sentinel-only list may combine with groups (platform slot options).
93
95
  pc = ProxyConfiguration(proxy_urls=['auto'], groups=['RESIDENTIAL'])
@@ -222,7 +224,7 @@ async def test_subdivision_and_country_validation() -> None:
222
224
  with pytest.raises(ValueError, match='1-3 character ISO 3166-2'):
223
225
  ProxyConfiguration(country_code='US', subdivision_code='ca')
224
226
  # Subdivision combined with real BYOP URLs is a conflicting combination.
225
- with pytest.raises(ValueError, match='Cannot combine custom proxies with Apify Proxy'):
227
+ with pytest.raises(ValueError, match='Cannot combine custom proxies with Scrapely Proxy'):
226
228
  ProxyConfiguration(
227
229
  proxy_urls=['http://byok.example.com:8080'],
228
230
  country_code='US',
@@ -500,6 +502,50 @@ async def test_access_check_runs_for_mixed_byop_and_sentinel(
500
502
  assert any('access check succeeded' in r.message for r in caplog.records)
501
503
 
502
504
 
505
+ UNKNOWN_GROUP_ERROR = (
506
+ 'Proxy group "GOOGLE_SERP" is not available on Scrapely. Available groups: RESIDENTIAL, DATACENTER.'
507
+ )
508
+ REFUSED_PAYLOAD = json.dumps({'connected': False, 'connectionError': UNKNOWN_GROUP_ERROR, 'isManInTheMiddle': False})
509
+ CONNECTED_PAYLOAD = '{"connected": true, "isManInTheMiddle": false}'
510
+
511
+
512
+ def _proxy_username(auth: str | None) -> str:
513
+ import base64
514
+
515
+ return base64.b64decode((auth or '').removeprefix('Basic ')).decode().split(':')[0]
516
+
517
+
518
+ async def test_access_check_raises_gateway_connection_error(monkeypatch: pytest.MonkeyPatch) -> None:
519
+ with FakeGateway(200, REFUSED_PAYLOAD) as gateway:
520
+ _point_env_at_gateway(monkeypatch, gateway)
521
+ pc = ProxyConfiguration(groups=['GOOGLE_SERP'])
522
+ with pytest.raises(ConnectionError) as excinfo:
523
+ await pc.initialize()
524
+
525
+ assert str(excinfo.value) == UNKNOWN_GROUP_ERROR
526
+ assert [_proxy_username(auth) for _, auth in gateway.requests] == ['sub1,groups-GOOGLE_SERP']
527
+
528
+
529
+ async def test_access_check_known_groups_succeed(
530
+ monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
531
+ ) -> None:
532
+ import logging
533
+
534
+ with FakeGateway(200, CONNECTED_PAYLOAD) as gateway:
535
+ _point_env_at_gateway(monkeypatch, gateway)
536
+ with caplog.at_level(logging.INFO, logger='scrapely.proxy_configuration'):
537
+ for groups in (None, ['RESIDENTIAL'], ['DATACENTER']):
538
+ await ProxyConfiguration(groups=groups).initialize()
539
+
540
+ assert [_proxy_username(auth) for _, auth in gateway.requests] == [
541
+ 'sub1',
542
+ 'sub1,groups-RESIDENTIAL',
543
+ 'sub1,groups-DATACENTER',
544
+ ]
545
+ assert sum('access check succeeded' in r.message for r in caplog.records) == 3
546
+ assert not any(r.levelno >= logging.WARNING for r in caplog.records)
547
+
548
+
503
549
 
504
550
  # ── Actor.create_proxy_configuration useApifyProxy semantics (Node parity) ────
505
551
 
@@ -567,6 +613,98 @@ async def test_actor_create_use_apify_proxy_false_with_explicit_kwarg_keeps_byop
567
613
  assert await pc.new_url() == 'http://byop-b:8080'
568
614
 
569
615
 
616
+ # useScrapelyProxy is the canonical key; a boolean useScrapelyProxy wins over useApifyProxy.
617
+
618
+ async def test_actor_create_use_scrapely_proxy_true_uses_platform(initialized_actor) -> None:
619
+ pc = await initialized_actor.create_proxy_configuration(
620
+ actor_proxy_input={'useScrapelyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL']},
621
+ check_access=False,
622
+ )
623
+ assert pc is not None
624
+ assert 'groups-RESIDENTIAL' in await pc.new_url()
625
+
626
+
627
+ async def test_actor_create_use_scrapely_proxy_false_disables(initialized_actor) -> None:
628
+ pc = await initialized_actor.create_proxy_configuration(
629
+ actor_proxy_input={'useScrapelyProxy': False, 'apifyProxyGroups': ['RESIDENTIAL']},
630
+ check_access=False,
631
+ )
632
+ assert pc is None
633
+
634
+
635
+ async def test_actor_create_use_scrapely_proxy_false_with_proxy_urls_keeps_byop(initialized_actor) -> None:
636
+ pc = await initialized_actor.create_proxy_configuration(
637
+ actor_proxy_input={'useScrapelyProxy': False, 'proxyUrls': ['http://byop-a:8080']},
638
+ check_access=False,
639
+ )
640
+ assert pc is not None
641
+ assert await pc.new_url() == 'http://byop-a:8080'
642
+
643
+
644
+ async def test_actor_create_use_scrapely_proxy_wins_over_use_apify_proxy(initialized_actor) -> None:
645
+ off = await initialized_actor.create_proxy_configuration(
646
+ actor_proxy_input={'useScrapelyProxy': False, 'useApifyProxy': True},
647
+ check_access=False,
648
+ )
649
+ assert off is None
650
+ on = await initialized_actor.create_proxy_configuration(
651
+ actor_proxy_input={'useScrapelyProxy': True, 'useApifyProxy': False, 'apifyProxyGroups': ['RESIDENTIAL']},
652
+ check_access=False,
653
+ )
654
+ assert on is not None
655
+ assert 'groups-RESIDENTIAL' in await on.new_url()
656
+
657
+
658
+ def test_proxy_configuration_actor_input_use_scrapely_proxy() -> None:
659
+ pc = ProxyConfiguration(actor_proxy_input={'useScrapelyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL']})
660
+ assert pc._groups == ['RESIDENTIAL']
661
+ custom = ProxyConfiguration(actor_proxy_input={'useScrapelyProxy': False, 'proxyUrls': ['http://byop-c:8080']})
662
+ assert custom._proxy_urls == ['http://byop-c:8080']
663
+ assert custom._groups == []
664
+
665
+
666
+ def test_proxy_configuration_actor_input_scrapely_proxy_options() -> None:
667
+ pc = ProxyConfiguration(actor_proxy_input={
668
+ 'useScrapelyProxy': True,
669
+ 'scrapelyProxyGroups': ['RESIDENTIAL'],
670
+ 'scrapelyProxyCountry': 'US',
671
+ 'scrapelyProxySubdivision': 'CA',
672
+ })
673
+ assert pc._groups == ['RESIDENTIAL']
674
+ assert pc._country_code == 'US'
675
+ assert pc._subdivision_code == 'CA'
676
+ both = ProxyConfiguration(actor_proxy_input={
677
+ 'useScrapelyProxy': True,
678
+ 'scrapelyProxyGroups': ['RESIDENTIAL'],
679
+ 'apifyProxyGroups': ['DATACENTER'],
680
+ 'apifyProxyCountry': 'DE',
681
+ })
682
+ assert both._groups == ['RESIDENTIAL']
683
+ assert both._country_code == 'DE'
684
+
685
+
686
+ async def test_actor_create_scrapely_proxy_groups_uses_platform(initialized_actor) -> None:
687
+ pc = await initialized_actor.create_proxy_configuration(
688
+ actor_proxy_input={'useScrapelyProxy': True, 'scrapelyProxyGroups': ['RESIDENTIAL'], 'scrapelyProxyCountry': 'US'},
689
+ check_access=False,
690
+ )
691
+ assert pc is not None
692
+ url = await pc.new_url()
693
+ assert 'groups-RESIDENTIAL' in url
694
+ assert 'country-US' in url
695
+
696
+
697
+ async def test_actor_create_legacy_apify_proxy_groups_still_work(initialized_actor) -> None:
698
+ pc = await initialized_actor.create_proxy_configuration(
699
+ actor_proxy_input={'useApifyProxy': True, 'apifyProxyGroups': ['RESIDENTIAL'], 'apifyProxyCountry': 'US'},
700
+ check_access=False,
701
+ )
702
+ assert pc is not None
703
+ url = await pc.new_url()
704
+ assert 'groups-RESIDENTIAL' in url
705
+ assert 'country-US' in url
706
+
707
+
570
708
  async def test_actor_create_no_options_without_env_returns_none(
571
709
  initialized_actor, monkeypatch: pytest.MonkeyPatch
572
710
  ) -> None:
@@ -577,6 +715,37 @@ async def test_actor_create_no_options_without_env_returns_none(
577
715
  assert pc is None
578
716
 
579
717
 
718
+ async def test_actor_create_refused_group_raises(initialized_actor, monkeypatch: pytest.MonkeyPatch) -> None:
719
+ with FakeGateway(200, REFUSED_PAYLOAD) as gateway:
720
+ _point_env_at_gateway(monkeypatch, gateway)
721
+ with pytest.raises(ConnectionError, match='Proxy group "GOOGLE_SERP" is not available'):
722
+ await initialized_actor.create_proxy_configuration(groups=['GOOGLE_SERP'])
723
+ with pytest.raises(ConnectionError, match='Proxy group "GOOGLE_SERP" is not available'):
724
+ await initialized_actor.create_proxy_configuration(
725
+ actor_proxy_input={'useApifyProxy': True, 'apifyProxyGroups': ['GOOGLE_SERP']}
726
+ )
727
+
728
+ assert [_proxy_username(auth) for _, auth in gateway.requests] == [
729
+ 'sub1,groups-GOOGLE_SERP',
730
+ 'sub1,groups-GOOGLE_SERP',
731
+ ]
732
+
733
+
734
+ async def test_actor_create_http_errors_and_timeouts_only_warn(
735
+ initialized_actor, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
736
+ ) -> None:
737
+ import logging
738
+
739
+ with caplog.at_level(logging.WARNING, logger='scrapely.proxy_configuration'):
740
+ with FakeGateway(407, '') as gateway:
741
+ _point_env_at_gateway(monkeypatch, gateway)
742
+ assert await initialized_actor.create_proxy_configuration(groups=['GOOGLE_SERP']) is not None
743
+ assert await initialized_actor.create_proxy_configuration(groups=['GOOGLE_SERP']) is not None
744
+
745
+ assert any('authentication failed (HTTP 407)' in r.message for r in caplog.records)
746
+ assert any('access check timed out' in r.message for r in caplog.records)
747
+
748
+
580
749
  async def test_actor_create_proxy_urls_from_input_without_flag(initialized_actor) -> None:
581
750
  pc = await initialized_actor.create_proxy_configuration(
582
751
  actor_proxy_input={'proxyUrls': ['http://byop-a:8080']},