scrapely-python 0.1.12__tar.gz → 0.1.14__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 (68) hide show
  1. {scrapely_python-0.1.12/src/scrapely_python.egg-info → scrapely_python-0.1.14}/PKG-INFO +1 -1
  2. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/pyproject.toml +1 -1
  3. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/__init__.py +1 -1
  4. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/actor.py +27 -6
  5. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/configuration.py +16 -0
  6. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/api_client.py +5 -1
  7. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/client.py +9 -1
  8. {scrapely_python-0.1.12 → scrapely_python-0.1.14/src/scrapely_python.egg-info}/PKG-INFO +1 -1
  9. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_apify_parity.py +50 -0
  10. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_parity.py +6 -1
  11. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/LICENSE +0 -0
  12. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/LICENSE-APACHE +0 -0
  13. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/NOTICE +0 -0
  14. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/README.md +0 -0
  15. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/setup.cfg +0 -0
  16. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/_browser_locations.py +0 -0
  17. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/browser_configuration.py +0 -0
  18. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/cdp_retry.py +0 -0
  19. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/charging.py +0 -0
  20. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/charging_client.py +0 -0
  21. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/crypto/__init__.py +0 -0
  22. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/crypto/_secrets.py +0 -0
  23. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/errors.py +0 -0
  24. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/events/__init__.py +0 -0
  25. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/events/_types.py +0 -0
  26. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/events/event_manager.py +0 -0
  27. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/log.py +0 -0
  28. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/patch_browser_pool.py +0 -0
  29. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/pricing_types.py +0 -0
  30. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/proxy_configuration.py +0 -0
  31. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/py.typed +0 -0
  32. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/request_loaders/__init__.py +0 -0
  33. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
  34. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/__init__.py +0 -0
  35. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/_file_system/__init__.py +0 -0
  36. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
  37. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
  38. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/_metadata.py +0 -0
  39. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/patch_storage.py +0 -0
  40. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
  41. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
  42. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/scrapely_rq_client.py +0 -0
  43. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/scrapely_storage_client.py +0 -0
  44. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
  45. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/types.py +0 -0
  46. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage/utils.py +0 -0
  47. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storage_clients/__init__.py +0 -0
  48. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/storages/__init__.py +0 -0
  49. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/types.py +0 -0
  50. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely/webhook.py +0 -0
  51. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely_python.egg-info/SOURCES.txt +0 -0
  52. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
  53. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely_python.egg-info/requires.txt +0 -0
  54. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/src/scrapely_python.egg-info/top_level.txt +0 -0
  55. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_api_client_retries.py +0 -0
  56. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_browser_configuration.py +0 -0
  57. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_cdp_retry.py +0 -0
  58. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_charge_limit_guard.py +0 -0
  59. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_charge_retries.py +0 -0
  60. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_charging_parity.py +0 -0
  61. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_charging_pending.py +0 -0
  62. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_input_proxy_aliases.py +0 -0
  63. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_patch_browser_pool.py +0 -0
  64. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_proxy_configuration.py +0 -0
  65. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_run_control.py +0 -0
  66. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_secrets.py +0 -0
  67. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/tests/test_storage.py +0 -0
  68. {scrapely_python-0.1.12 → scrapely_python-0.1.14}/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.12
3
+ Version: 0.1.14
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.12"
7
+ version = "0.1.14"
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.12'
135
+ __version__ = '0.1.14'
136
136
  __all__ = [
137
137
  # Core
138
138
  'Actor',
@@ -213,6 +213,14 @@ class _ClassProperty:
213
213
  return self.func(owner)
214
214
 
215
215
 
216
+ def _system_exit_code(exc: Any) -> int:
217
+ """Process exit code carried by a SystemExit (sys.exit semantics)."""
218
+ code = getattr(exc, 'code', None)
219
+ if code is None:
220
+ return 0
221
+ return code if isinstance(code, int) else 1
222
+
223
+
216
224
  class _ActorMeta(type):
217
225
  """Metaclass enabling async with Actor: by delegating to classmethods."""
218
226
 
@@ -226,7 +234,11 @@ class _ActorMeta(type):
226
234
  return cls
227
235
 
228
236
  async def __aexit__(cls, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
229
- if exc_type is not None:
237
+ if exc_type is not None and issubclass(exc_type, SystemExit):
238
+ # Actor.exit()/fail()/sys.exit() inside `async with Actor:` is a deliberate
239
+ # exit, not a failure (Apify parity): keep its exit code, log nothing.
240
+ cls._exit_code = _system_exit_code(exc_val)
241
+ elif exc_type is not None:
230
242
  cls._exit_code = 1
231
243
  logger.error(f'Actor failed: {exc_val}')
232
244
  await cls._shutdown()
@@ -286,7 +298,10 @@ class Actor(metaclass=_ActorMeta):
286
298
 
287
299
  async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
288
300
  cls = type(self)
289
- if exc_type is not None:
301
+ if exc_type is not None and issubclass(exc_type, SystemExit):
302
+ # Deliberate exit via Actor.exit()/fail()/sys.exit(), not a failure.
303
+ cls._exit_code = _system_exit_code(exc_val)
304
+ elif exc_type is not None:
290
305
  cls._exit_code = 1
291
306
  logger.error(f'Actor failed: {exc_val}')
292
307
  else:
@@ -652,12 +667,12 @@ class Actor(metaclass=_ActorMeta):
652
667
 
653
668
  @classmethod
654
669
  def is_at_home(cls) -> bool:
655
- return cls._config.get('is_at_home', False) if cls._config else False
670
+ return bool(cls._env_config().get('is_at_home', False))
656
671
 
657
672
  @classmethod
658
673
  def is_standby(cls) -> bool:
659
674
  """Return True when the actor run was started in standby mode (Node SDK ``isStandby`` parity)."""
660
- return (cls._config.get('meta_origin') == 'STANDBY') if cls._config else False
675
+ return cls._env_config().get('meta_origin') == 'STANDBY'
661
676
 
662
677
  @_ClassProperty
663
678
  def is_paying(cls) -> bool:
@@ -721,7 +736,7 @@ class Actor(metaclass=_ActorMeta):
721
736
  return cast(Dict[str, Any], cls._cached_input)
722
737
 
723
738
  kvs = await cls.open_key_value_store()
724
- input_key = cls._config.get('input_key', 'INPUT') if cls._config else 'INPUT'
739
+ input_key = cls._env_config().get('input_key', 'INPUT')
725
740
  result = await kvs.get_value(input_key)
726
741
  if isinstance(result, dict):
727
742
  result = decrypt_secret_inputs(result)
@@ -848,7 +863,7 @@ class Actor(metaclass=_ActorMeta):
848
863
  name = name_or_id
849
864
 
850
865
  if alias is not None:
851
- storages_json = cls._config.get('actor_storages_json') if cls._config else None
866
+ storages_json = cls._env_config().get('actor_storages_json')
852
867
  resolved = resolve_alias_from_storages_json(alias, storage_type, storages_json)
853
868
  if resolved is not None:
854
869
  id = resolved
@@ -1129,6 +1144,12 @@ class Actor(metaclass=_ActorMeta):
1129
1144
  cls._config = Configuration.get_global_config()
1130
1145
  return cls._config
1131
1146
 
1147
+ @classmethod
1148
+ def _env_config(cls) -> Configuration:
1149
+ # Apify parity: is_at_home() and friends read the environment even
1150
+ # before init(). Doesn't set cls._config, so init() state is unchanged.
1151
+ return cls._config if cls._config is not None else Configuration.get_global_config()
1152
+
1132
1153
  @classmethod
1133
1154
  def new_client(cls, token: Optional[str] = None, base_url: Optional[str] = None) -> ApiClient:
1134
1155
  config = cls.get_config()
@@ -266,6 +266,22 @@ class Configuration:
266
266
  value = self.get('standby_port')
267
267
  return value if isinstance(value, int) else 4321
268
268
 
269
+ @property
270
+ def web_server_port(self) -> int:
271
+ """Apify name for the container port (ACTOR_WEB_SERVER_PORT, default 4321)."""
272
+ value = self.get('container_port')
273
+ return value if isinstance(value, int) else 4321
274
+
275
+ @property
276
+ def web_server_url(self) -> str:
277
+ """Apify name for the container URL (ACTOR_WEB_SERVER_URL)."""
278
+ return cast(str, self.get('container_url'))
279
+
280
+ @property
281
+ def standby_url(self) -> str:
282
+ """Public standby URL (ACTOR_STANDBY_URL), falling back to the container URL."""
283
+ return cast(str, self.get('standby_url') or self.get('container_url'))
284
+
269
285
  @property
270
286
  def token(self) -> Optional[str]:
271
287
  """Get the authentication token."""
@@ -5,6 +5,7 @@ Base API client for Scrapely storage operations.
5
5
  from __future__ import annotations
6
6
 
7
7
  import asyncio
8
+ import json
8
9
  import logging
9
10
  import os
10
11
  import random
@@ -311,6 +312,10 @@ class ApiClient:
311
312
  request_headers = dict(headers or {})
312
313
  if json_data is not None and 'Content-Type' not in request_headers:
313
314
  request_headers['Content-Type'] = 'application/json'
315
+ if json_data is not None:
316
+ # Apify/crawlee parity: unknown objects (datetimes, LLM result
317
+ # objects, ...) are stored as strings instead of raising.
318
+ content = json.dumps(json_data, ensure_ascii=False, default=str).encode('utf-8')
314
319
 
315
320
  if idempotent is None:
316
321
  idempotent = method.upper() in IDEMPOTENT_METHODS
@@ -343,7 +348,6 @@ class ApiClient:
343
348
  method,
344
349
  url,
345
350
  params=params,
346
- json=json_data,
347
351
  content=content,
348
352
  headers=request_headers or None,
349
353
  **request_kwargs,
@@ -6,6 +6,7 @@ Ports the patterns from the TypeScript ApiStorageClient.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  import logging
10
11
  import re
11
12
  import uuid
@@ -358,7 +359,14 @@ class ApiStorageClient:
358
359
  return cast(Dict[str, Any], await self.api_client.get_json(f'/v2/key-value-stores/{store_id}/keys', params=params))
359
360
 
360
361
  def kvs_public_url(self, store_id: str, key: str) -> str:
361
- return f'{self.api_client.api_url}{kvs_record_path(store_id, key)}'
362
+ # api_url is the container-internal API address; links handed to users
363
+ # must use the public base URL the platform injects.
364
+ public_base = (
365
+ os.environ.get('APIFY_API_PUBLIC_BASE_URL')
366
+ or os.environ.get('ACTOR_API_PUBLIC_BASE_URL')
367
+ or self.api_client.api_url
368
+ ).rstrip('/')
369
+ return f'{public_base}{kvs_record_path(store_id, key)}'
362
370
 
363
371
  # --- Request queue resource ops ---
364
372
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrapely-python
3
- Version: 0.1.12
3
+ Version: 0.1.14
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
@@ -9,6 +9,7 @@ update them when targeting a newer Apify version.
9
9
  from __future__ import annotations
10
10
 
11
11
  import importlib
12
+ import json
12
13
 
13
14
  import pytest
14
15
 
@@ -83,3 +84,52 @@ def test_configuration_has_apify_global_accessor() -> None:
83
84
  from scrapely import Configuration
84
85
 
85
86
  assert Configuration.get_global_configuration() is Configuration.get_global_config()
87
+
88
+
89
+ def test_configuration_has_apify_web_server_names(monkeypatch: pytest.MonkeyPatch) -> None:
90
+ from scrapely import Configuration
91
+
92
+ monkeypatch.setenv('ACTOR_WEB_SERVER_PORT', '5555')
93
+ monkeypatch.setenv('ACTOR_WEB_SERVER_URL', 'http://10.0.0.1:5555')
94
+ config = Configuration()
95
+ assert config.web_server_port == 5555
96
+ assert config.web_server_url == 'http://10.0.0.1:5555'
97
+ assert config.standby_url == 'http://10.0.0.1:5555'
98
+
99
+
100
+ def test_is_at_home_reads_env_before_init(monkeypatch: pytest.MonkeyPatch) -> None:
101
+ # Apify templates pick their port at import time, before Actor.init().
102
+ from scrapely import Actor, Configuration
103
+
104
+ monkeypatch.setenv('APIFY_IS_AT_HOME', '1')
105
+ monkeypatch.setenv('APIFY_META_ORIGIN', 'STANDBY')
106
+ Configuration.reset_global_state()
107
+ monkeypatch.setattr(Actor, '_config', None)
108
+ try:
109
+ assert Actor.is_at_home() is True
110
+ assert Actor.is_standby() is True
111
+ assert Actor._config is None
112
+ finally:
113
+ Configuration.reset_global_state()
114
+
115
+
116
+ async def test_json_body_stringifies_unknown_objects() -> None:
117
+ import httpx
118
+
119
+ from scrapely.storage.api_client import ApiClient
120
+
121
+ class AgentOutput:
122
+ def __str__(self) -> str:
123
+ return 'the answer'
124
+
125
+ bodies: list = []
126
+
127
+ def handler(request: httpx.Request) -> httpx.Response:
128
+ bodies.append((request.headers['content-type'], request.content))
129
+ return httpx.Response(201, json={})
130
+
131
+ client = ApiClient('http://api.test', 'st_x', 'run1', transport=httpx.MockTransport(handler))
132
+ await client.post_json('/v2/datasets/d1/items', [{'answer': AgentOutput(), 'q': 'café'}])
133
+ content_type, body = bodies[0]
134
+ assert content_type == 'application/json'
135
+ assert json.loads(body) == [{'answer': 'the answer', 'q': 'café'}]
@@ -64,13 +64,18 @@ def test_actor_is_standby_reflects_meta_origin() -> None:
64
64
  Actor._config = original
65
65
 
66
66
 
67
- def test_actor_is_standby_without_config_is_false() -> None:
67
+ def test_actor_is_standby_without_config_is_false(monkeypatch: pytest.MonkeyPatch) -> None:
68
+ # Before init() is_standby() reads the environment (Apify parity), so clear it.
69
+ monkeypatch.delenv('APIFY_META_ORIGIN', raising=False)
70
+ monkeypatch.delenv('ACTOR_META_ORIGIN', raising=False)
68
71
  original = Actor._config
69
72
  try:
70
73
  Actor._config = None
74
+ scrapely.Configuration.reset_global_state()
71
75
  assert Actor.is_standby() is False
72
76
  finally:
73
77
  Actor._config = original
78
+ scrapely.Configuration.reset_global_state()
74
79
 
75
80
 
76
81
  def test_parity_helpers_exported_from_package_root() -> None: