scrapely-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. scrapely/__init__.py +246 -0
  2. scrapely/_browser_locations.py +5470 -0
  3. scrapely/actor.py +1836 -0
  4. scrapely/browser_configuration.py +146 -0
  5. scrapely/charging.py +1143 -0
  6. scrapely/charging_client.py +107 -0
  7. scrapely/configuration.py +375 -0
  8. scrapely/crypto/__init__.py +21 -0
  9. scrapely/crypto/_secrets.py +210 -0
  10. scrapely/errors.py +153 -0
  11. scrapely/events/__init__.py +57 -0
  12. scrapely/events/_types.py +194 -0
  13. scrapely/events/event_manager.py +292 -0
  14. scrapely/log.py +39 -0
  15. scrapely/patch_browser_pool.py +861 -0
  16. scrapely/pricing_types.py +172 -0
  17. scrapely/proxy_configuration.py +741 -0
  18. scrapely/py.typed +2 -0
  19. scrapely/request_loaders/__init__.py +5 -0
  20. scrapely/request_loaders/_scrapely_request_list.py +169 -0
  21. scrapely/storage/__init__.py +48 -0
  22. scrapely/storage/_file_system/__init__.py +9 -0
  23. scrapely/storage/_file_system/_key_value_store_client.py +155 -0
  24. scrapely/storage/_file_system/_storage_client.py +50 -0
  25. scrapely/storage/_metadata.py +138 -0
  26. scrapely/storage/api_client.py +350 -0
  27. scrapely/storage/client.py +618 -0
  28. scrapely/storage/patch_storage.py +257 -0
  29. scrapely/storage/scrapely_dataset_client.py +305 -0
  30. scrapely/storage/scrapely_kvs_client.py +220 -0
  31. scrapely/storage/scrapely_rq_client.py +496 -0
  32. scrapely/storage/scrapely_storage_client.py +192 -0
  33. scrapely/storage/smart_scrapely_storage_client.py +109 -0
  34. scrapely/storage/types.py +601 -0
  35. scrapely/storage/utils.py +38 -0
  36. scrapely/storage_clients/__init__.py +25 -0
  37. scrapely/storages/__init__.py +5 -0
  38. scrapely/types.py +269 -0
  39. scrapely/webhook.py +82 -0
  40. scrapely_python-0.1.0.dist-info/METADATA +83 -0
  41. scrapely_python-0.1.0.dist-info/RECORD +43 -0
  42. scrapely_python-0.1.0.dist-info/WHEEL +5 -0
  43. scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,210 @@
1
+ """Input secret decryption for the Scrapely Python SDK.
2
+
3
+ Port of the Node SDK's ``src/crypto/secrets.ts``. The platform encrypts secret
4
+ Actor input values with RSA-OAEP (SHA-256) and delivers the RSA private key to
5
+ the container as a passphrase-encrypted file (AES-256-GCM with a PBKDF2-derived
6
+ key). This module decrypts the private key, decrypts each secret field, and
7
+ merges the plaintext values back into the Actor input.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any, Dict
19
+
20
+ from cryptography.hazmat.primitives import hashes, serialization
21
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
22
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ─── Constants (must match the Node SDK's crypto/secrets.ts) ──────────────────
27
+
28
+ _IV_LENGTH = 12
29
+ _AUTH_TAG_LENGTH = 16
30
+ _PBKDF2_ITERATIONS = 100_000
31
+ _PBKDF2_HASH = 'sha256'
32
+ _SALT_LENGTH = 32
33
+ _KEY_LENGTH = 32
34
+
35
+ _OAEP_PADDING = padding.OAEP(
36
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
37
+ algorithm=hashes.SHA256(),
38
+ label=None,
39
+ )
40
+
41
+
42
+ def decrypt_private_key_file(private_key_file_path: str, passphrase: str) -> str:
43
+ """Read the private key file from disk and decrypt it with the run passphrase.
44
+
45
+ The file layout is ``salt(32) | iv(12) | authTag(16) | ciphertext``, where the
46
+ AES-256-GCM key is derived from the passphrase with PBKDF2-HMAC-SHA256.
47
+
48
+ Args:
49
+ private_key_file_path: Path to the encrypted private key file
50
+ (e.g. ``/run/secrets/private_key.pem``).
51
+ passphrase: The run passphrase from ``APIFY_INPUT_SECRETS_PRIVATE_KEY_PASSPHRASE``.
52
+
53
+ Returns:
54
+ The decrypted private key as a PEM string.
55
+ """
56
+ encrypted_data = Path(private_key_file_path).read_bytes()
57
+
58
+ salt = encrypted_data[:_SALT_LENGTH]
59
+ iv = encrypted_data[_SALT_LENGTH:_SALT_LENGTH + _IV_LENGTH]
60
+ auth_tag = encrypted_data[_SALT_LENGTH + _IV_LENGTH:_SALT_LENGTH + _IV_LENGTH + _AUTH_TAG_LENGTH]
61
+ encrypted = encrypted_data[_SALT_LENGTH + _IV_LENGTH + _AUTH_TAG_LENGTH:]
62
+
63
+ key = hashlib.pbkdf2_hmac(
64
+ _PBKDF2_HASH,
65
+ passphrase.encode('utf-8'),
66
+ salt,
67
+ _PBKDF2_ITERATIONS,
68
+ dklen=_KEY_LENGTH,
69
+ )
70
+
71
+ # The ``cryptography`` AESGCM helper expects ciphertext || tag (the file
72
+ # stores tag before the ciphertext, like Node's setAuthTag() flow).
73
+ decrypted = AESGCM(key).decrypt(iv, encrypted + auth_tag, None)
74
+
75
+ return decrypted.decode('utf-8')
76
+
77
+
78
+ def decrypt_secret_value(encrypted_base64: str, private_key_pem: str) -> Any:
79
+ """Decrypt a single secret value encrypted with RSA-OAEP (SHA-256).
80
+
81
+ Args:
82
+ encrypted_base64: Base64-encoded encrypted data.
83
+ private_key_pem: The decrypted private key PEM.
84
+
85
+ Returns:
86
+ The decrypted value; JSON-shaped plaintext is parsed into Python objects,
87
+ anything else is returned as a plain string.
88
+ """
89
+ encrypted = base64.b64decode(encrypted_base64)
90
+
91
+ private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None)
92
+ if not isinstance(private_key, rsa.RSAPrivateKey):
93
+ raise ValueError('The decrypted private key is not an RSA key')
94
+ decrypted_data = private_key.decrypt(encrypted, _OAEP_PADDING)
95
+
96
+ plaintext = decrypted_data.decode('utf-8')
97
+
98
+ # Try to parse as JSON (for objects, arrays, numbers, booleans)
99
+ try:
100
+ return json.loads(plaintext)
101
+ except json.JSONDecodeError:
102
+ return plaintext
103
+
104
+
105
+ def decrypt_all_secrets(
106
+ encrypted_secrets: Dict[str, str],
107
+ private_key_pem: str,
108
+ ) -> Dict[str, Any]:
109
+ """Decrypt all secret values from the encrypted input payload.
110
+
111
+ Args:
112
+ encrypted_secrets: Map of field name to base64 encrypted value.
113
+ private_key_pem: The decrypted private key PEM.
114
+
115
+ Returns:
116
+ Map of field name to decrypted value; fields that fail to decrypt are
117
+ skipped with a warning (matching the Node SDK behavior).
118
+ """
119
+ decrypted: Dict[str, Any] = {}
120
+
121
+ for field_name, encrypted_base64 in encrypted_secrets.items():
122
+ try:
123
+ decrypted[field_name] = decrypt_secret_value(encrypted_base64, private_key_pem)
124
+ except Exception as err: # noqa: BLE001 - a bad field must not kill the run
125
+ logger.warning('Failed to decrypt secret field "%s": %s', field_name, err)
126
+
127
+ return decrypted
128
+
129
+
130
+ def merge_decrypted_input(
131
+ input_data: Dict[str, Any],
132
+ decrypted_secrets: Dict[str, Any],
133
+ ) -> Dict[str, Any]:
134
+ """Merge decrypted secret values into the original input object.
135
+
136
+ Supports nested paths like ``credentials.apiKey`` or ``database.password``.
137
+
138
+ Args:
139
+ input_data: The original input object (with secrets stripped/masked).
140
+ decrypted_secrets: Map of field name to decrypted value.
141
+
142
+ Returns:
143
+ A new input object with secrets populated.
144
+ """
145
+ merged = dict(input_data)
146
+
147
+ for field_name, value in decrypted_secrets.items():
148
+ if '.' in field_name:
149
+ # Handle nested paths
150
+ parts = field_name.split('.')
151
+ current = merged
152
+ for part in parts[:-1]:
153
+ if not isinstance(current.get(part), dict):
154
+ current[part] = {}
155
+ current = current[part]
156
+ current[parts[-1]] = value
157
+ else:
158
+ merged[field_name] = value
159
+
160
+ return merged
161
+
162
+
163
+ def decrypt_secret_inputs(input_data: Dict[str, Any]) -> Dict[str, Any]:
164
+ """Full decryption flow: read the private key file, decrypt it with the passphrase,
165
+ then decrypt all secrets and merge them into the input.
166
+
167
+ This is called from ``Actor.get_input()`` when
168
+ ``APIFY_INPUT_SECRETS_PRIVATE_KEY_FILE`` is set.
169
+
170
+ Args:
171
+ input_data: Current Actor input object.
172
+
173
+ Returns:
174
+ The input object with decrypted secret values merged in; on failure the
175
+ input is returned as-is so the Actor can still run.
176
+ """
177
+ private_key_file = os.environ.get('APIFY_INPUT_SECRETS_PRIVATE_KEY_FILE')
178
+ passphrase = os.environ.get('APIFY_INPUT_SECRETS_PRIVATE_KEY_PASSPHRASE')
179
+
180
+ if not private_key_file or not passphrase:
181
+ return input_data # No secrets to decrypt
182
+
183
+ logger.info('Decrypting secret inputs...')
184
+
185
+ try:
186
+ # 1. Read and decrypt the private key file
187
+ private_key_pem = decrypt_private_key_file(private_key_file, passphrase)
188
+ logger.info('Private key decrypted successfully')
189
+
190
+ # 2. Get the encrypted secrets from the input
191
+ # The encrypted secrets are stored in a special __encrypted_secrets property
192
+ encrypted_secrets = input_data.get('__encrypted_secrets')
193
+
194
+ if not encrypted_secrets or not isinstance(encrypted_secrets, dict) or not encrypted_secrets:
195
+ logger.info('No encrypted secrets found in input')
196
+ return input_data
197
+
198
+ # 3. Decrypt all secret values
199
+ decrypted_secrets = decrypt_all_secrets(encrypted_secrets, private_key_pem)
200
+ logger.info('Decrypted %d secret field(s)', len(decrypted_secrets))
201
+
202
+ # 4. Merge decrypted values into input and remove the __encrypted_secrets key
203
+ merged = merge_decrypted_input(input_data, decrypted_secrets)
204
+ merged.pop('__encrypted_secrets', None)
205
+ merged.pop('__encrypted_secret_fields', None)
206
+
207
+ return merged
208
+ except Exception as err: # noqa: BLE001 - secrets won't be available but actor can still run
209
+ logger.error('Failed to decrypt secret inputs: %s', err)
210
+ return input_data
scrapely/errors.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Error classes for the Scrapely Python SDK.
3
+
4
+ This module provides a hierarchy of error classes for handling API errors,
5
+ following the same pattern as the Apify SDK.
6
+
7
+ Hierarchy:
8
+ ScrapelyClientError
9
+ ├── ScrapelyApiError
10
+ │ ├── InvalidRequestError (400)
11
+ │ ├── UnauthorizedError (401)
12
+ │ ├── ForbiddenError (403)
13
+ │ ├── NotFoundError (404)
14
+ │ ├── ConflictError (409)
15
+ │ ├── RateLimitError (429)
16
+ │ └── ServerError (5xx)
17
+ └── InvalidResponseBodyError
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Type
23
+
24
+ if TYPE_CHECKING:
25
+ import httpx
26
+
27
+
28
+ class ScrapelyClientError(Exception):
29
+ """Base class for all Scrapely API client errors."""
30
+
31
+ pass
32
+
33
+
34
+ class InvalidResponseBodyError(ScrapelyClientError):
35
+ """Error raised when a response body cannot be parsed."""
36
+
37
+ def __init__(self, response: 'httpx.Response') -> None:
38
+ self.response = response
39
+ super().__init__(f'Invalid response body: {response.text[:200]}')
40
+
41
+
42
+ class ScrapelyApiError(ScrapelyClientError):
43
+ """Error raised when the Scrapely API returns an error response."""
44
+
45
+ def __init__(
46
+ self,
47
+ response: 'httpx.Response',
48
+ attempt: int,
49
+ *,
50
+ method: str = 'GET',
51
+ ) -> None:
52
+ self.response = response
53
+ self.attempt = attempt
54
+ self.method = method
55
+ self.status_code = response.status_code
56
+
57
+ self.type: Optional[str] = None
58
+ self.message: Optional[str] = None
59
+ self.data: Optional[Dict[str, Any]] = None
60
+
61
+ try:
62
+ body = response.json()
63
+ self.type = body.get('type')
64
+ self.message = body.get('message')
65
+ self.data = body.get('data')
66
+ except Exception:
67
+ pass
68
+
69
+ error_parts = [f'HTTP {response.status_code}']
70
+ if self.type:
71
+ error_parts.append(f'({self.type})')
72
+ if self.message:
73
+ error_parts.append(f'- {self.message}')
74
+ error_parts.append(f'[attempt {attempt}]')
75
+
76
+ super().__init__(' '.join(error_parts))
77
+
78
+
79
+ class InvalidRequestError(ScrapelyApiError):
80
+ """Raised when the Scrapely API returns an HTTP 400 Bad Request response."""
81
+ pass
82
+
83
+
84
+ class UnauthorizedError(ScrapelyApiError):
85
+ """Raised when the Scrapely API returns an HTTP 401 Unauthorized response."""
86
+ pass
87
+
88
+
89
+ class ForbiddenError(ScrapelyApiError):
90
+ """Raised when the Scrapely API returns an HTTP 403 Forbidden response."""
91
+ pass
92
+
93
+
94
+ class NotFoundError(ScrapelyApiError):
95
+ """Raised when the Scrapely API returns an HTTP 404 Not Found response."""
96
+ pass
97
+
98
+
99
+ class ConflictError(ScrapelyApiError):
100
+ """Raised when the Scrapely API returns an HTTP 409 Conflict response."""
101
+ pass
102
+
103
+
104
+ class RateLimitError(ScrapelyApiError):
105
+ """Raised when the Scrapely API returns an HTTP 429 Too Many Requests response."""
106
+ pass
107
+
108
+
109
+ class ServerError(ScrapelyApiError):
110
+ """Raised when the Scrapely API returns an HTTP 5xx response."""
111
+ pass
112
+
113
+
114
+ _STATUS_CODE_TO_ERROR: Dict[int, Type[ScrapelyApiError]] = {
115
+ 400: InvalidRequestError,
116
+ 401: UnauthorizedError,
117
+ 403: ForbiddenError,
118
+ 404: NotFoundError,
119
+ 409: ConflictError,
120
+ 429: RateLimitError,
121
+ }
122
+
123
+
124
+ def raise_for_status(response: 'httpx.Response', attempt: int = 1, *, method: str = 'GET') -> None:
125
+ """Raise an appropriate error for the given HTTP response."""
126
+ if response.status_code < 400:
127
+ return
128
+
129
+ status_code = response.status_code
130
+ error_class = _STATUS_CODE_TO_ERROR.get(status_code)
131
+
132
+ if error_class is None and 500 <= status_code < 600:
133
+ error_class = ServerError
134
+
135
+ if error_class is None:
136
+ error_class = ScrapelyApiError
137
+
138
+ raise error_class(response, attempt, method=method)
139
+
140
+
141
+ __all__ = [
142
+ 'ScrapelyClientError',
143
+ 'ScrapelyApiError',
144
+ 'InvalidResponseBodyError',
145
+ 'InvalidRequestError',
146
+ 'UnauthorizedError',
147
+ 'ForbiddenError',
148
+ 'NotFoundError',
149
+ 'ConflictError',
150
+ 'RateLimitError',
151
+ 'ServerError',
152
+ 'raise_for_status',
153
+ ]
@@ -0,0 +1,57 @@
1
+ """
2
+ Event management for Scrapely Actors.
3
+
4
+ Exports all event types and data models matching the Apify SDK.
5
+ """
6
+
7
+ from crawlee.events import EventManager as CrawleeEventManager
8
+ from crawlee.events import LocalEventManager
9
+ from crawlee.events._types import (
10
+ EventAbortingData,
11
+ EventExitData,
12
+ EventMigratingData,
13
+ EventPersistStateData,
14
+ EventSystemInfoData,
15
+ )
16
+
17
+ from ._types import (
18
+ AbortingEvent,
19
+ ActorEventTypes,
20
+ DeprecatedEvent,
21
+ Event,
22
+ EventMessage,
23
+ EventWithoutData,
24
+ ExitEvent,
25
+ MigratingEvent,
26
+ PersistStateEvent,
27
+ SystemInfoEvent,
28
+ SystemInfoEventData,
29
+ UnknownEvent,
30
+ )
31
+ from .event_manager import EventManager, ScrapelyEventManager
32
+
33
+ __all__ = [
34
+ 'Event',
35
+ 'EventManager',
36
+ 'ScrapelyEventManager',
37
+ 'CrawleeEventManager',
38
+ 'LocalEventManager',
39
+ # Event data types
40
+ 'SystemInfoEventData',
41
+ 'EventAbortingData',
42
+ 'EventExitData',
43
+ 'EventMigratingData',
44
+ 'EventPersistStateData',
45
+ 'EventSystemInfoData',
46
+ # Event types
47
+ 'PersistStateEvent',
48
+ 'SystemInfoEvent',
49
+ 'MigratingEvent',
50
+ 'AbortingEvent',
51
+ 'ExitEvent',
52
+ 'EventWithoutData',
53
+ 'DeprecatedEvent',
54
+ 'UnknownEvent',
55
+ 'EventMessage',
56
+ 'ActorEventTypes',
57
+ ]
@@ -0,0 +1,194 @@
1
+ """Scrapely event types - extends Crawlee's event types with Scrapely-specific data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+ from pydantic.alias_generators import to_camel
10
+
11
+ from crawlee.events._types import (
12
+ Event,
13
+ EventAbortingData,
14
+ EventExitData,
15
+ EventMigratingData,
16
+ EventPersistStateData,
17
+ EventSystemInfoData,
18
+ )
19
+
20
+ # Re-export Event for convenience
21
+ __all__ = [
22
+ 'Event',
23
+ 'SystemInfoEventData',
24
+ 'PersistStateEvent',
25
+ 'SystemInfoEvent',
26
+ 'MigratingEvent',
27
+ 'AbortingEvent',
28
+ 'ExitEvent',
29
+ 'EventWithoutData',
30
+ 'DeprecatedEvent',
31
+ 'UnknownEvent',
32
+ 'EventMessage',
33
+ 'ActorEventTypes',
34
+ ]
35
+
36
+ ActorEventTypes = Literal['systemInfo', 'persistState', 'migrating', 'aborting']
37
+ """Event types emitted by the Scrapely platform during an Actor run.
38
+
39
+ This is the Scrapely-specific subset of `Event`. For the full set (including framework-level events
40
+ like `SESSION_RETIRED` or `BROWSER_LAUNCHED`), use `Event` from `scrapely`.
41
+ """
42
+
43
+
44
+ class SystemInfoEventData(BaseModel):
45
+ """Resource usage metrics carried by a `systemInfo` event."""
46
+
47
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
48
+
49
+ mem_avg_bytes: float
50
+ """Average memory usage over the measured interval, in bytes."""
51
+
52
+ mem_current_bytes: float
53
+ """Current memory usage, in bytes."""
54
+
55
+ mem_max_bytes: float
56
+ """Peak memory usage observed so far, in bytes."""
57
+
58
+ cpu_avg_usage: float
59
+ """Average CPU usage over the measured interval, in percent."""
60
+
61
+ cpu_max_usage: float
62
+ """Peak CPU usage observed so far, in percent."""
63
+
64
+ cpu_current_usage: float
65
+ """Current CPU usage, in percent."""
66
+
67
+ is_cpu_overloaded: bool
68
+ """Whether the CPU is currently overloaded."""
69
+
70
+ created_at: datetime
71
+ """Timestamp when the metrics were collected."""
72
+
73
+ def to_crawlee_format(self, dedicated_cpus: float) -> EventSystemInfoData:
74
+ """Convert to Crawlee's EventSystemInfoData format."""
75
+ return EventSystemInfoData.model_validate(
76
+ {
77
+ 'cpu_info': {
78
+ 'used_ratio': (self.cpu_current_usage / 100) / dedicated_cpus,
79
+ 'created_at': self.created_at,
80
+ },
81
+ 'memory_info': {
82
+ 'total_size': self.mem_max_bytes,
83
+ 'current_size': self.mem_current_bytes,
84
+ 'created_at': self.created_at,
85
+ },
86
+ }
87
+ )
88
+
89
+
90
+ class PersistStateEvent(BaseModel):
91
+ """A `persistState` event instructing the Actor to persist its state."""
92
+
93
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
94
+
95
+ name: Literal[Event.PERSIST_STATE]
96
+ """The event name."""
97
+
98
+ data: Annotated[EventPersistStateData, Field(default_factory=lambda: EventPersistStateData(is_migrating=False))]
99
+ """The event payload."""
100
+
101
+
102
+ class SystemInfoEvent(BaseModel):
103
+ """A `systemInfo` event carrying the Actor's resource usage metrics."""
104
+
105
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
106
+
107
+ name: Literal[Event.SYSTEM_INFO]
108
+ """The event name."""
109
+
110
+ data: SystemInfoEventData
111
+ """The event payload."""
112
+
113
+
114
+ class MigratingEvent(BaseModel):
115
+ """A `migrating` event signalling the Actor is about to be migrated to another host."""
116
+
117
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
118
+
119
+ name: Literal[Event.MIGRATING]
120
+ """The event name."""
121
+
122
+ data: Annotated[EventMigratingData, Field(default_factory=EventMigratingData)]
123
+ """The event payload."""
124
+
125
+
126
+ class AbortingEvent(BaseModel):
127
+ """An `aborting` event signalling the Actor run is being aborted."""
128
+
129
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
130
+
131
+ name: Literal[Event.ABORTING]
132
+ """The event name."""
133
+
134
+ data: Annotated[EventAbortingData, Field(default_factory=EventAbortingData)]
135
+ """The event payload."""
136
+
137
+
138
+ class ExitEvent(BaseModel):
139
+ """An `exit` event signalling the Actor process is about to exit."""
140
+
141
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
142
+
143
+ name: Literal[Event.EXIT]
144
+ """The event name."""
145
+
146
+ data: Annotated[EventExitData, Field(default_factory=EventExitData)]
147
+ """The event payload."""
148
+
149
+
150
+ class EventWithoutData(BaseModel):
151
+ """A framework-level event that carries no payload (e.g. browser and page lifecycle events)."""
152
+
153
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
154
+
155
+ name: Literal[
156
+ Event.SESSION_RETIRED,
157
+ Event.BROWSER_LAUNCHED,
158
+ Event.BROWSER_RETIRED,
159
+ Event.BROWSER_CLOSED,
160
+ Event.PAGE_CREATED,
161
+ Event.PAGE_CLOSED,
162
+ ]
163
+ """The event name."""
164
+
165
+ data: Any = None
166
+ """The event payload, always empty for this event."""
167
+
168
+
169
+ class DeprecatedEvent(BaseModel):
170
+ """A deprecated event kept for backward compatibility (e.g. `cpuInfo`)."""
171
+
172
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
173
+
174
+ name: Literal['cpuInfo']
175
+ """The event name."""
176
+
177
+ data: Annotated[dict[str, Any], Field(default_factory=dict)]
178
+ """The event payload."""
179
+
180
+
181
+ class UnknownEvent(BaseModel):
182
+ """A fallback for any event whose name is not recognized by the SDK."""
183
+
184
+ model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
185
+
186
+ name: str
187
+ """The event name."""
188
+
189
+ data: Annotated[dict[str, Any], Field(default_factory=dict)]
190
+ """The event payload."""
191
+
192
+
193
+ EventMessage = PersistStateEvent | SystemInfoEvent | MigratingEvent | AbortingEvent | ExitEvent | EventWithoutData
194
+ """Union of all recognized event message types."""