scrapely-python 0.1.33__tar.gz → 0.1.35__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.33 → scrapely_python-0.1.35}/CHANGELOG.md +9 -0
  2. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/PKG-INFO +1 -1
  3. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/pyproject.toml +1 -1
  4. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/__init__.py +1 -1
  5. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/actor.py +96 -7
  6. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely_python.egg-info/PKG-INFO +1 -1
  7. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/LICENSE +0 -0
  8. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/LICENSE-APACHE +0 -0
  9. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/MANIFEST.in +0 -0
  10. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/NOTICE +0 -0
  11. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/README.md +0 -0
  12. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/setup.cfg +0 -0
  13. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/_browser_locations.py +0 -0
  14. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/browser_configuration.py +0 -0
  15. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/cdp_retry.py +0 -0
  16. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/charging.py +0 -0
  17. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/charging_client.py +0 -0
  18. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/configuration.py +0 -0
  19. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/crypto/__init__.py +0 -0
  20. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/crypto/_secrets.py +0 -0
  21. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/errors.py +0 -0
  22. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/events/__init__.py +0 -0
  23. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/events/_types.py +0 -0
  24. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/events/event_manager.py +0 -0
  25. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/log.py +0 -0
  26. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/patch_browser_pool.py +0 -0
  27. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/pricing_types.py +0 -0
  28. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/proxy_configuration.py +0 -0
  29. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/py.typed +0 -0
  30. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/request_loaders/__init__.py +0 -0
  31. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  32. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/__init__.py +0 -0
  33. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/_actor_runner.py +0 -0
  34. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/_async_thread.py +0 -0
  35. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/_logging_config.py +0 -0
  36. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/_serialization.py +0 -0
  37. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/_warnings.py +0 -0
  38. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/extensions/__init__.py +0 -0
  39. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/extensions/_graceful_stop.py +0 -0
  40. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/extensions/_httpcache.py +0 -0
  41. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/middlewares/__init__.py +0 -0
  42. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/middlewares/apify_proxy.py +0 -0
  43. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/pipelines/__init__.py +0 -0
  44. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/pipelines/actor_dataset_push.py +0 -0
  45. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/requests.py +0 -0
  46. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/scheduler.py +0 -0
  47. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/scrapy/utils.py +0 -0
  48. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/solver.py +0 -0
  49. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/__init__.py +0 -0
  50. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/_file_system/__init__.py +0 -0
  51. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  52. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  53. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/_metadata.py +0 -0
  54. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/api_client.py +0 -0
  55. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/client.py +0 -0
  56. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/patch_storage.py +0 -0
  57. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  58. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  59. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  60. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  61. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  62. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/types.py +0 -0
  63. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage/utils.py +0 -0
  64. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storage_clients/__init__.py +0 -0
  65. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/storages/__init__.py +0 -0
  66. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/types.py +0 -0
  67. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely/webhook.py +0 -0
  68. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  69. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  70. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely_python.egg-info/requires.txt +0 -0
  71. {scrapely_python-0.1.33 → scrapely_python-0.1.35}/src/scrapely_python.egg-info/top_level.txt +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.35] - 2026-10-26
6
+
7
+ - Once `Actor.fail()` has run, the run stays failed. A later `Actor.exit()` keeps the failure's exit code and status message, so the usual `try: ... await Actor.fail(...) finally: await Actor.exit()` pattern now ends the run as FAILED. Before, the `exit()` in `finally` replaced the failure's exit code 1 with 0 and the run ended as SUCCEEDED with the failure message as its status. The same holds after `fail()` whose `SystemExit` was caught, after `fail(ExitOptions(exit=False))` (also inside `async with Actor(...)` and `Actor.main()`), and for a second `fail()`, which keeps the first failure's code and message.
8
+ - `Actor.fail(ExitOptions(...))` without an exit code exits with 1. `ExitOptions` defaults `exit_code` to 0, which made such a failure exit with 0. A non-zero `exit_code` in the options, or an explicit `exit_code=` keyword, still wins.
9
+
10
+ ## [0.1.34] - 2026-10-23
11
+
12
+ - Debug captures (`Actor.debug_capture`, `Actor.debug_capture_sync`) store parsed XML/HTML elements as their markup instead of a repr such as `<Element 'change' at 0x...>`: `xml.etree.ElementTree` elements and trees, lxml elements and trees, BeautifulSoup tags, selectolax nodes and parsel / Scrapy selectors, passed alone or inside a dict or list. The text after an element (its tail) is not included. Other objects are still stored as their `str()`. Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
13
+
5
14
  ## [0.1.33] - 2026-10-22
6
15
 
7
16
  - Debug captures (`Actor.debug_capture`, `Actor.debug_capture_sync`) keep payloads over 256 KiB whole. They are stored gzip-compressed and base64-encoded as `{key, payload, encoding: 'gzip-base64', length, truncated}` instead of being cut at 262,144 characters; up to 8 Mi characters are kept. A payload that does not compress enough keeps its first 256 KiB, now marked `truncated: True` with its full `length`. Dict and list payloads are dumped as compact JSON, and `bytes` payloads are stored as UTF-8 text (they used to be stored as their `repr`). Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.33
3
+ Version: 0.1.35
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.33"
7
+ version = "0.1.35"
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"
@@ -227,7 +227,7 @@ if TYPE_CHECKING: # Static analysers and IDEs see the lazy names as plain impor
227
227
  set_api_storage,
228
228
  )
229
229
 
230
- __version__ = '0.1.33'
230
+ __version__ = '0.1.35'
231
231
  __all__ = [
232
232
  # Core
233
233
  'Actor',
@@ -285,6 +285,53 @@ class _ActorMeta(type):
285
285
  await cls._shutdown()
286
286
 
287
287
 
288
+
289
+ def _element_markup(obj: Any) -> Optional[str]:
290
+ """The markup of a parsed XML/HTML element, or None when ``obj`` is not one.
291
+
292
+ Debug captures store the source an item was read from. Feed and page parsers hand over elements
293
+ (xml.etree / lxml elements and trees, BeautifulSoup tags, selectolax nodes, parsel / Scrapy
294
+ selectors); their ``str()`` is only a repr such as ``<Element 'change' at 0x...>``, which shows
295
+ nothing of the source. Optional parsers are recognised by module, never imported here.
296
+ """
297
+ import xml.etree.ElementTree as ET
298
+
299
+ if isinstance(obj, ET.ElementTree):
300
+ root = obj.getroot()
301
+ return ET.tostring(root, encoding='unicode') if root is not None else ''
302
+ if isinstance(obj, ET.Element):
303
+ if obj.tail: # the text after the element belongs to its parent, as with lxml's with_tail=False
304
+ import copy
305
+
306
+ obj = copy.copy(obj)
307
+ obj.tail = None
308
+ return ET.tostring(obj, encoding='unicode')
309
+ modules = [getattr(c, '__module__', '') or '' for c in type(obj).__mro__]
310
+ if any(m == 'lxml' or m.startswith('lxml.') for m in modules):
311
+ from lxml import etree # type: ignore[import-not-found]
312
+
313
+ out = etree.tostring(obj, encoding='unicode', with_tail=False)
314
+ return out if isinstance(out, str) else None
315
+ if any(m == 'bs4' or m.startswith('bs4.') for m in modules):
316
+ return str(obj)
317
+ if any(m == 'selectolax' or m.startswith('selectolax.') for m in modules):
318
+ html = getattr(obj, 'html', None)
319
+ return html if isinstance(html, str) else None
320
+ if any(m == 'parsel' or m.startswith('parsel.') for m in modules) and callable(getattr(obj, 'get', None)):
321
+ out = obj.get()
322
+ return out if isinstance(out, str) else None
323
+ return None
324
+
325
+
326
+ def _capture_json_default(obj: Any) -> Any:
327
+ """json.dumps fallback for debug captures: elements as their markup, anything else as str()."""
328
+ try:
329
+ markup = _element_markup(obj)
330
+ except Exception: # an unusual node type must not lose the rest of the payload
331
+ markup = None
332
+ return markup if markup is not None else str(obj)
333
+
334
+
288
335
  class Actor(metaclass=_ActorMeta):
289
336
  """Core class for building Actors on the Scrapely platform."""
290
337
 
@@ -302,6 +349,10 @@ class Actor(metaclass=_ActorMeta):
302
349
  _cached_input: Optional[Dict[str, Any]] = None
303
350
  _exit_code: int = 0
304
351
  _status_message: Optional[str] = None
352
+ # Set by fail() for the rest of the lifecycle: (exit code, status message).
353
+ # A later exit() keeps them, so `fail()` followed by the usual `exit()` in a
354
+ # `finally:` block still ends the run as FAILED (it used to exit with 0).
355
+ _failure: Optional[Tuple[int, Optional[str]]] = None
305
356
  _charging_manager: Optional[ChargingManager] = None
306
357
  _use_state_stores: set = set()
307
358
  # Lifecycle bookkeeping set in init() / cleared in _shutdown().
@@ -345,9 +396,11 @@ class Actor(metaclass=_ActorMeta):
345
396
  elif exc_type is not None:
346
397
  cls._exit_code = 1
347
398
  logger.error(f'Actor failed: {exc_val}')
399
+ elif cls._failure is not None:
400
+ cls._exit_code = cls._failure[0] # fail(exit=False) earlier: the run stays failed
348
401
  else:
349
402
  cls._exit_code = self._instance_exit_code
350
- if self._instance_status_message:
403
+ if self._instance_status_message and cls._failure is None:
351
404
  cls._status_message = self._instance_status_message
352
405
  await cls._persist_terminal_status_message(self._instance_status_message)
353
406
  await cls._shutdown(timeout_secs=int(self._cleanup_timeout.total_seconds()))
@@ -363,6 +416,7 @@ class Actor(metaclass=_ActorMeta):
363
416
  cls._main_loop = asyncio.get_running_loop() # for debug_capture_sync from worker threads
364
417
  if cls._initialized:
365
418
  return
419
+ cls._failure = None # a new lifecycle starts unfailed
366
420
 
367
421
  if options is None or options.configure_logging:
368
422
  _lazy('_configure_logging')()
@@ -654,13 +708,33 @@ class Actor(metaclass=_ActorMeta):
654
708
  if exit_code is not None:
655
709
  opts.exit_code = exit_code
656
710
 
657
- if opts.status_message:
711
+ if cls._failure is not None:
712
+ # Actor.fail() already ran: the run stays failed. Keep its exit code and
713
+ # status message; a later exit() (e.g. in a `finally:` block) only
714
+ # finishes the shutdown and exits with the failure's code.
715
+ if opts.status_message:
716
+ logger.info(f'[Scrapely] {opts.status_message}')
717
+ logger.info(
718
+ '[Scrapely] Actor.exit() after Actor.fail(): the run stays failed (exit code %s).',
719
+ cls._failure[0],
720
+ )
721
+ opts = ExitOptions(**{**opts.__dict__})
722
+ opts.exit_code, opts.status_message = cls._failure
723
+ await cls._exit_with(opts, persist_message=False)
724
+ return
725
+
726
+ await cls._exit_with(opts)
727
+
728
+ @classmethod
729
+ async def _exit_with(cls, opts: ExitOptions, persist_message: bool = True) -> None:
730
+ if opts.status_message and persist_message:
658
731
  logger.info(f'[Scrapely] {opts.status_message}')
659
732
 
660
733
  cls._exit_code = opts.exit_code
661
734
  cls._status_message = opts.status_message
662
735
 
663
- await cls._persist_terminal_status_message(opts.status_message)
736
+ if persist_message:
737
+ await cls._persist_terminal_status_message(opts.status_message)
664
738
  await cls._shutdown(timeout_secs=opts.timeout_secs)
665
739
 
666
740
  if opts.exit:
@@ -689,7 +763,7 @@ class Actor(metaclass=_ActorMeta):
689
763
  if isinstance(message_or_options, str):
690
764
  opts = ExitOptions(exit_code=1, status_message=message_or_options)
691
765
  else:
692
- # Options-object form: an explicit exit_code in the object wins over
766
+ # Options-object form: a non-zero exit_code in the object wins over
693
767
  # the fail() default of 1 (JS SDK semantics). Build the dict first —
694
768
  # passing it alongside exit_code=1 as kwargs raises
695
769
  # "got multiple values for keyword argument 'exit_code'".
@@ -704,12 +778,24 @@ class Actor(metaclass=_ActorMeta):
704
778
  opts.status_message = status_message
705
779
  if exit_code is not None:
706
780
  opts.exit_code = exit_code
781
+ elif not opts.exit_code:
782
+ # ExitOptions always carries exit_code (default 0), so 0 from an options
783
+ # object means "not set": a failed run must not exit with 0. Only an
784
+ # explicit exit_code=0 keyword keeps 0.
785
+ opts.exit_code = 1
707
786
 
708
787
  logger.error(
709
788
  f'[Scrapely] Actor failed: {opts.status_message or "Unknown error"}',
710
789
  exc_info=exception,
711
790
  )
712
- await cls.exit(opts)
791
+ if cls._failure is not None:
792
+ # Failed already: the first failure's code and message stay.
793
+ opts = ExitOptions(**{**opts.__dict__})
794
+ opts.exit_code, opts.status_message = cls._failure
795
+ await cls._exit_with(opts, persist_message=False)
796
+ return
797
+ cls._failure = (opts.exit_code, opts.status_message)
798
+ await cls._exit_with(opts)
713
799
 
714
800
  @classmethod
715
801
  def is_at_home(cls) -> bool:
@@ -974,12 +1060,15 @@ class Actor(metaclass=_ActorMeta):
974
1060
  if cls._debug_capture_count >= cls._DEBUG_CAPTURE_MAX_RECORDS:
975
1061
  return
976
1062
  try:
977
- if isinstance(payload, (bytes, bytearray)):
1063
+ markup = _element_markup(payload)
1064
+ if markup is not None:
1065
+ text = markup
1066
+ elif isinstance(payload, (bytes, bytearray)):
978
1067
  text = bytes(payload).decode('utf-8', 'replace')
979
1068
  elif isinstance(payload, str):
980
1069
  text = payload
981
1070
  else:
982
- text = json.dumps(payload, default=str, ensure_ascii=False, separators=(',', ':'))
1071
+ text = json.dumps(payload, default=_capture_json_default, ensure_ascii=False, separators=(',', ':'))
983
1072
  record = cls._debug_capture_record(str(key), text)
984
1073
  cls._debug_capture_count += 1
985
1074
  await cls.set_value(cls.debug_capture_record_key(key), record)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.33
3
+ Version: 0.1.35
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