rayon-sdk-py 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.
- rayon_sdk_py/__init__.py +26 -0
- rayon_sdk_py/client.py +78 -0
- rayon_sdk_py/errors.py +46 -0
- rayon_sdk_py/models.py +16 -0
- rayon_sdk_py/validation.py +208 -0
- rayon_sdk_py-0.1.0.dist-info/METADATA +125 -0
- rayon_sdk_py-0.1.0.dist-info/RECORD +10 -0
- rayon_sdk_py-0.1.0.dist-info/WHEEL +5 -0
- rayon_sdk_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- rayon_sdk_py-0.1.0.dist-info/top_level.txt +1 -0
rayon_sdk_py/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Synchronous client for the public Rayon API."""
|
|
2
|
+
|
|
3
|
+
from .client import solve
|
|
4
|
+
from .models import SolveResult
|
|
5
|
+
from .errors import (
|
|
6
|
+
HTTPError,
|
|
7
|
+
InvalidResponseError,
|
|
8
|
+
RayonError,
|
|
9
|
+
RedirectError,
|
|
10
|
+
SolveFailedError,
|
|
11
|
+
TransportError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__all__ = [
|
|
17
|
+
"solve",
|
|
18
|
+
"SolveResult",
|
|
19
|
+
"RayonError",
|
|
20
|
+
"ValidationError",
|
|
21
|
+
"TransportError",
|
|
22
|
+
"HTTPError",
|
|
23
|
+
"RedirectError",
|
|
24
|
+
"InvalidResponseError",
|
|
25
|
+
"SolveFailedError",
|
|
26
|
+
]
|
rayon_sdk_py/client.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from curl_cffi import CurlOpt, requests
|
|
2
|
+
from curl_cffi.curl import CURL_WRITEFUNC_ERROR
|
|
3
|
+
|
|
4
|
+
from .errors import (
|
|
5
|
+
HTTPError,
|
|
6
|
+
InvalidResponseError,
|
|
7
|
+
RedirectError,
|
|
8
|
+
SolveFailedError,
|
|
9
|
+
TransportError,
|
|
10
|
+
)
|
|
11
|
+
from .models import SolveResult
|
|
12
|
+
from .validation import parse_result, response_metadata, validate_request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def solve(
|
|
19
|
+
target_url: str,
|
|
20
|
+
api_key: str,
|
|
21
|
+
*,
|
|
22
|
+
profile: dict | None = None,
|
|
23
|
+
base_url: str = "https://api.rayonapi.com",
|
|
24
|
+
timeout: float = 150,
|
|
25
|
+
) -> SolveResult:
|
|
26
|
+
"""Capture a telemetry header family with one synchronous API request."""
|
|
27
|
+
profile = validate_request(target_url, api_key, profile, base_url, timeout)
|
|
28
|
+
body = {"target_url": target_url}
|
|
29
|
+
if profile is not None:
|
|
30
|
+
body["profile"] = profile
|
|
31
|
+
content = bytearray()
|
|
32
|
+
oversized = False
|
|
33
|
+
|
|
34
|
+
def collect(chunk):
|
|
35
|
+
nonlocal oversized
|
|
36
|
+
if len(content) + len(chunk) > _MAX_RESPONSE_BYTES:
|
|
37
|
+
oversized = True
|
|
38
|
+
return CURL_WRITEFUNC_ERROR
|
|
39
|
+
content.extend(chunk)
|
|
40
|
+
return len(chunk)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Some curl_cffi versions leave libcurl's environment proxy lookup enabled.
|
|
44
|
+
with requests.Session(
|
|
45
|
+
impersonate="chrome", trust_env=False, curl_options={CurlOpt.PROXY: ""}
|
|
46
|
+
) as session:
|
|
47
|
+
response = session.post(
|
|
48
|
+
base_url.rstrip("/") + "/solve",
|
|
49
|
+
json=body,
|
|
50
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
allow_redirects=False,
|
|
53
|
+
content_callback=collect,
|
|
54
|
+
)
|
|
55
|
+
except requests.exceptions.RequestException:
|
|
56
|
+
if oversized:
|
|
57
|
+
raise InvalidResponseError(
|
|
58
|
+
"Rayon API response exceeded the 2 MiB limit."
|
|
59
|
+
) from None
|
|
60
|
+
raise TransportError("Rayon API request could not be completed.") from None
|
|
61
|
+
response.content = bytes(content)
|
|
62
|
+
metadata = response_metadata(response, api_key)
|
|
63
|
+
if 300 <= response.status_code < 400:
|
|
64
|
+
raise RedirectError(
|
|
65
|
+
"Rayon API returned a redirect; no redirect was followed.", **metadata
|
|
66
|
+
)
|
|
67
|
+
if response.status_code != 200:
|
|
68
|
+
raise HTTPError("Rayon API returned an unsuccessful HTTP status.", **metadata)
|
|
69
|
+
try:
|
|
70
|
+
body = response.json()
|
|
71
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
72
|
+
raise InvalidResponseError(
|
|
73
|
+
"Rayon API returned invalid JSON.", **metadata
|
|
74
|
+
) from None
|
|
75
|
+
result = parse_result(body, target_url, metadata)
|
|
76
|
+
if result.status != "accepted":
|
|
77
|
+
raise SolveFailedError(result, **metadata)
|
|
78
|
+
return result
|
rayon_sdk_py/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from .models import SolveResult
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RayonError(Exception):
|
|
5
|
+
"""Base class for all SDK failures; messages never include API response bodies."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
request_id: str | None = None,
|
|
13
|
+
retry_after: str | None = None,
|
|
14
|
+
):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.request_id = request_id
|
|
18
|
+
self.retry_after = retry_after
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ValidationError(RayonError, ValueError):
|
|
22
|
+
"""A request argument is invalid; no API request was sent."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransportError(RayonError):
|
|
26
|
+
"""The API request could not be completed."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HTTPError(RayonError):
|
|
30
|
+
"""The API returned an unsuccessful HTTP status."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RedirectError(HTTPError):
|
|
34
|
+
"""The API returned a redirect, which the SDK did not follow."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidResponseError(RayonError):
|
|
38
|
+
"""The API response was not valid JSON or did not match the contract."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SolveFailedError(RayonError):
|
|
42
|
+
"""The API returned a valid failed or rejected solve result."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, result: SolveResult, **metadata):
|
|
45
|
+
super().__init__("Rayon solve was not accepted.", **metadata)
|
|
46
|
+
self.result = result
|
rayon_sdk_py/models.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class SolveResult:
|
|
7
|
+
"""Captured telemetry and the metadata needed to replay it."""
|
|
8
|
+
|
|
9
|
+
status: str
|
|
10
|
+
method: str
|
|
11
|
+
target_url: str = field(repr=False)
|
|
12
|
+
duration_ms: int
|
|
13
|
+
headers: dict[str, str] = field(repr=False)
|
|
14
|
+
cookies: list[dict[str, Any]] = field(repr=False)
|
|
15
|
+
profile_used: dict[str, Any] = field(repr=False)
|
|
16
|
+
errors: list[str] = field(repr=False)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from email.utils import format_datetime, parsedate_to_datetime
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from .errors import InvalidResponseError, ValidationError
|
|
8
|
+
from .models import SolveResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def response_metadata(response, api_key):
|
|
12
|
+
request_id = response.headers.get("X-Request-ID")
|
|
13
|
+
if (
|
|
14
|
+
not isinstance(request_id, str)
|
|
15
|
+
or api_key in request_id
|
|
16
|
+
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", request_id)
|
|
17
|
+
):
|
|
18
|
+
request_id = None
|
|
19
|
+
retry_after = response.headers.get("Retry-After")
|
|
20
|
+
if (
|
|
21
|
+
not isinstance(retry_after, str)
|
|
22
|
+
or api_key in retry_after
|
|
23
|
+
or len(retry_after) > 128
|
|
24
|
+
):
|
|
25
|
+
retry_after = None
|
|
26
|
+
elif not re.fullmatch(r"[0-9]{1,10}", retry_after):
|
|
27
|
+
try:
|
|
28
|
+
parsed = parsedate_to_datetime(retry_after)
|
|
29
|
+
if format_datetime(parsed, usegmt=True) != retry_after:
|
|
30
|
+
retry_after = None
|
|
31
|
+
except (ValueError, TypeError, OverflowError):
|
|
32
|
+
retry_after = None
|
|
33
|
+
return {
|
|
34
|
+
"status_code": response.status_code,
|
|
35
|
+
"request_id": request_id,
|
|
36
|
+
"retry_after": retry_after,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _json_value(value, seen=None):
|
|
41
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
42
|
+
return True
|
|
43
|
+
if isinstance(value, float):
|
|
44
|
+
return math.isfinite(value)
|
|
45
|
+
if not isinstance(value, (dict, list)):
|
|
46
|
+
return False
|
|
47
|
+
seen = set() if seen is None else seen
|
|
48
|
+
if id(value) in seen:
|
|
49
|
+
return False
|
|
50
|
+
seen.add(id(value))
|
|
51
|
+
try:
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
return all(
|
|
54
|
+
isinstance(k, str) and _json_value(v, seen) for k, v in value.items()
|
|
55
|
+
)
|
|
56
|
+
return all(_json_value(item, seen) for item in value)
|
|
57
|
+
finally:
|
|
58
|
+
seen.remove(id(value))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _valid_url(value: str, *, base=False):
|
|
62
|
+
if not isinstance(value, str) or not value or len(value) > 8192:
|
|
63
|
+
return False
|
|
64
|
+
if any(ord(char) <= 32 or ord(char) == 127 for char in value) or "\\" in value:
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
parts = urlsplit(value)
|
|
68
|
+
port = parts.port
|
|
69
|
+
if (
|
|
70
|
+
parts.scheme not in {"http", "https"}
|
|
71
|
+
or not parts.hostname
|
|
72
|
+
or parts.username is not None
|
|
73
|
+
or parts.password is not None
|
|
74
|
+
or "#" in value
|
|
75
|
+
):
|
|
76
|
+
return False
|
|
77
|
+
if base and (
|
|
78
|
+
"?" in value
|
|
79
|
+
or (
|
|
80
|
+
parts.scheme == "http"
|
|
81
|
+
and parts.hostname not in {"localhost", "127.0.0.1", "::1"}
|
|
82
|
+
)
|
|
83
|
+
):
|
|
84
|
+
return False
|
|
85
|
+
return port is None or 0 < port <= 65535
|
|
86
|
+
except ValueError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_request(target_url, api_key, profile, base_url, timeout):
|
|
91
|
+
if not _valid_url(target_url):
|
|
92
|
+
raise ValidationError(
|
|
93
|
+
"target_url must be an HTTP(S) URL without credentials or a fragment."
|
|
94
|
+
)
|
|
95
|
+
if (
|
|
96
|
+
not isinstance(api_key, str)
|
|
97
|
+
or not api_key
|
|
98
|
+
or any(not 33 <= ord(char) <= 126 for char in api_key)
|
|
99
|
+
):
|
|
100
|
+
raise ValidationError(
|
|
101
|
+
"api_key must be a nonempty ASCII token without whitespace."
|
|
102
|
+
)
|
|
103
|
+
if not _valid_url(base_url, base=True):
|
|
104
|
+
raise ValidationError(
|
|
105
|
+
"base_url must be HTTPS (or local HTTP) without credentials, query, or fragment."
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
valid_timeout = (
|
|
109
|
+
isinstance(timeout, (int, float))
|
|
110
|
+
and not isinstance(timeout, bool)
|
|
111
|
+
and math.isfinite(timeout)
|
|
112
|
+
and timeout > 0
|
|
113
|
+
)
|
|
114
|
+
except OverflowError:
|
|
115
|
+
valid_timeout = False
|
|
116
|
+
if not valid_timeout:
|
|
117
|
+
raise ValidationError("timeout must be a finite positive number of seconds.")
|
|
118
|
+
try:
|
|
119
|
+
if profile is not None and (
|
|
120
|
+
not isinstance(profile, dict)
|
|
121
|
+
or not _json_value(profile)
|
|
122
|
+
or ("profile" in profile and not isinstance(profile["profile"], dict))
|
|
123
|
+
):
|
|
124
|
+
raise ValueError
|
|
125
|
+
return (
|
|
126
|
+
None
|
|
127
|
+
if profile is None
|
|
128
|
+
else json.loads(json.dumps(profile, allow_nan=False))
|
|
129
|
+
)
|
|
130
|
+
except (ValueError, TypeError, RecursionError, OverflowError):
|
|
131
|
+
raise ValidationError(
|
|
132
|
+
"profile must be a JSON object with finite values and string keys."
|
|
133
|
+
) from None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_result(body, target_url, metadata):
|
|
137
|
+
required = {
|
|
138
|
+
"status",
|
|
139
|
+
"method",
|
|
140
|
+
"target_url",
|
|
141
|
+
"duration_ms",
|
|
142
|
+
"headers",
|
|
143
|
+
"cookies",
|
|
144
|
+
"profile_used",
|
|
145
|
+
"errors",
|
|
146
|
+
}
|
|
147
|
+
valid = isinstance(body, dict) and required <= body.keys()
|
|
148
|
+
if valid:
|
|
149
|
+
valid = (
|
|
150
|
+
body["status"] in ("accepted", "failed", "rejected")
|
|
151
|
+
and body["method"] in ("v8_sandbox", "playwright")
|
|
152
|
+
and body["target_url"] == target_url
|
|
153
|
+
and type(body["duration_ms"]) is int
|
|
154
|
+
and body["duration_ms"] >= 0
|
|
155
|
+
and isinstance(body["headers"], dict)
|
|
156
|
+
and isinstance(body["cookies"], list)
|
|
157
|
+
and isinstance(body["profile_used"], dict)
|
|
158
|
+
and isinstance(body["errors"], list)
|
|
159
|
+
and all(isinstance(error, str) for error in body["errors"])
|
|
160
|
+
)
|
|
161
|
+
if valid:
|
|
162
|
+
try:
|
|
163
|
+
valid = _json_value(body["profile_used"]) and all(
|
|
164
|
+
isinstance(cookie, dict)
|
|
165
|
+
and {"name", "value", "domain", "path"} <= cookie.keys()
|
|
166
|
+
and all(
|
|
167
|
+
isinstance(cookie[field], str)
|
|
168
|
+
for field in ("name", "value", "domain", "path")
|
|
169
|
+
)
|
|
170
|
+
and _json_value(cookie)
|
|
171
|
+
for cookie in body["cookies"]
|
|
172
|
+
)
|
|
173
|
+
except RecursionError:
|
|
174
|
+
valid = False
|
|
175
|
+
if valid:
|
|
176
|
+
headers = body["headers"]
|
|
177
|
+
valid = all(
|
|
178
|
+
isinstance(name, str)
|
|
179
|
+
and name.isascii()
|
|
180
|
+
and isinstance(value, str)
|
|
181
|
+
and not any(
|
|
182
|
+
ord(char) < 32 or ord(char) == 127 or ord(char) > 255 for char in value
|
|
183
|
+
)
|
|
184
|
+
for name, value in headers.items()
|
|
185
|
+
)
|
|
186
|
+
if valid and body["status"] == "accepted":
|
|
187
|
+
families = []
|
|
188
|
+
suffixes = []
|
|
189
|
+
for name, value in headers.items():
|
|
190
|
+
match = re.fullmatch(
|
|
191
|
+
r"([!#$%&'*+\-.^_`|~0-9A-Za-z]+)-([abcdfz])", name, re.IGNORECASE
|
|
192
|
+
)
|
|
193
|
+
if not match or (match[2].lower() != "d" and not value.strip()):
|
|
194
|
+
valid = False
|
|
195
|
+
break
|
|
196
|
+
families.append(match[1].lower())
|
|
197
|
+
suffixes.append(match[2].lower())
|
|
198
|
+
valid = (
|
|
199
|
+
valid
|
|
200
|
+
and len(headers) == 6
|
|
201
|
+
and len(set(families)) == 1
|
|
202
|
+
and set(suffixes) == set("abcdfz")
|
|
203
|
+
)
|
|
204
|
+
if not valid:
|
|
205
|
+
raise InvalidResponseError(
|
|
206
|
+
"Rayon API returned an invalid solve response.", **metadata
|
|
207
|
+
)
|
|
208
|
+
return SolveResult(**{field: body[field] for field in required})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rayon-sdk-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronous Python client for the Rayon API
|
|
5
|
+
Author: Rayon API
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://rayonapi.com
|
|
8
|
+
Project-URL: Repository, https://github.com/Rayon-API/rayon-sdk-py
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: curl_cffi<1,>=0.13
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
20
|
+
Requires-Dist: twine>=6; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Rayon Python SDK
|
|
24
|
+
|
|
25
|
+
A synchronous client for `POST /solve` on the Rayon API. Python 3.10 or newer is required.
|
|
26
|
+
|
|
27
|
+
Install from PyPI:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python -m pip install rayon-sdk-py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
from rayon_sdk_py import solve
|
|
36
|
+
|
|
37
|
+
result = solve(
|
|
38
|
+
"https://example.com/page",
|
|
39
|
+
os.environ["RAYON_API_KEY"],
|
|
40
|
+
# Optional JSON fingerprint values:
|
|
41
|
+
profile={"user_agent": "your browser user agent"},
|
|
42
|
+
)
|
|
43
|
+
print(result.status, result.method, result.duration_ms)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`solve(target_url, api_key, *, profile=None, base_url="https://api.rayonapi.com", timeout=150)`
|
|
47
|
+
performs one request using curl_cffi Chrome impersonation. Timeout is a finite positive
|
|
48
|
+
number of seconds. The SDK disables redirects and environment proxies, and never retries.
|
|
49
|
+
Decoded response bodies are limited to 2 MiB; larger responses stop the transfer
|
|
50
|
+
and raise `InvalidResponseError`.
|
|
51
|
+
Custom API endpoints require HTTPS; HTTP is supported only for `localhost`, `127.0.0.1`,
|
|
52
|
+
or `[::1]` development endpoints. URLs cannot contain credentials or fragments;
|
|
53
|
+
the API base URL also cannot contain a query.
|
|
54
|
+
|
|
55
|
+
The request body contains only `target_url` and, when supplied, `profile`.
|
|
56
|
+
The API key is sent as a Bearer authorization header. Use an account and target
|
|
57
|
+
domain approved for your application.
|
|
58
|
+
|
|
59
|
+
An accepted result contains exactly one six-header family with a variable prefix
|
|
60
|
+
and the suffixes `a`, `b`, `c`, `d`, `f`, and `z`; `d` may be empty.
|
|
61
|
+
Acceptance means the headers were captured. It does not mean the target website
|
|
62
|
+
verified them. `result.errors` can contain diagnostics from earlier solver tiers
|
|
63
|
+
even when a fallback succeeds.
|
|
64
|
+
|
|
65
|
+
`SolveResult` exposes `status`, `method`, `target_url`, `duration_ms`, `headers`,
|
|
66
|
+
`cookies`, `profile_used`, and `errors`. Headers, cookies, URL, profile, and diagnostics
|
|
67
|
+
are excluded from its repr because replay data can be sensitive.
|
|
68
|
+
|
|
69
|
+
Cookies remain a list of records with required string fields `name`, `value`,
|
|
70
|
+
`domain`, and `path`. Extra JSON metadata is preserved. Cookie scope matters:
|
|
71
|
+
do not flatten cookies into a name/value dictionary. The SDK does not invent
|
|
72
|
+
`secure`, expiry, or other browser attributes absent from the API response.
|
|
73
|
+
When replaying a result, preserve cookie domains and paths and use the returned
|
|
74
|
+
`profile_used` values for a compatible browser fingerprint.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from curl_cffi.requests import Cookies
|
|
78
|
+
|
|
79
|
+
jar = Cookies()
|
|
80
|
+
for cookie in result.cookies:
|
|
81
|
+
jar.set(cookie["name"], cookie["value"], domain=cookie["domain"], path=cookie["path"])
|
|
82
|
+
# result.headers, jar, and result.profile_used are available for your replay client.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Handle failures explicitly:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import os
|
|
89
|
+
from rayon_sdk_py import HTTPError, RayonError, SolveFailedError, solve
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
result = solve("https://example.com/page", os.environ["RAYON_API_KEY"])
|
|
93
|
+
except SolveFailedError as exc:
|
|
94
|
+
# HTTP 200 with a failed/rejected result also raises.
|
|
95
|
+
print(exc.result.status)
|
|
96
|
+
except HTTPError as exc:
|
|
97
|
+
print(exc.status_code, exc.request_id, exc.retry_after)
|
|
98
|
+
except RayonError as exc:
|
|
99
|
+
print(str(exc))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
All SDK errors inherit from `RayonError`. `ValidationError` is raised before
|
|
103
|
+
network access; `TransportError` covers network failures; `RedirectError` is an
|
|
104
|
+
`HTTPError`; `InvalidResponseError` covers invalid JSON or malformed responses.
|
|
105
|
+
Exception messages do not include response bodies, credentials, or underlying
|
|
106
|
+
transport messages. HTTP response errors expose `status_code`, `request_id`, and
|
|
107
|
+
`retry_after` (a validated seconds or HTTP date header string, when present).
|
|
108
|
+
Invalid diagnostic headers or values reflecting the API key are discarded.
|
|
109
|
+
Decide whether and when
|
|
110
|
+
to retry in your application; admitted solve attempts can consume quota.
|
|
111
|
+
|
|
112
|
+
For local development:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
python -m venv .venv
|
|
116
|
+
. .venv/bin/activate
|
|
117
|
+
python -m pip install -e '.[dev]'
|
|
118
|
+
python -m pytest
|
|
119
|
+
python -m build
|
|
120
|
+
python -m twine check dist/*
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tests are offline and patch the transport. CI tests Python 3.10, 3.12, and 3.14
|
|
124
|
+
and builds the wheel and source distribution. See [RELEASE.md](RELEASE.md) for
|
|
125
|
+
the trusted publisher setup. Licensed under [MIT](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
rayon_sdk_py/__init__.py,sha256=eCgjTsOFv8Ysk8fmUxDC7myKKNuZbLZSKFvNdA-FRec,495
|
|
2
|
+
rayon_sdk_py/client.py,sha256=D5ahI-kRqRG9j-yQrDR8Op2ywbxODEzGZdQuEU5Ryq4,2652
|
|
3
|
+
rayon_sdk_py/errors.py,sha256=P44laeaBsQVqp3e4aBJEUGYJMj7XhuQvYwULI6zC4XQ,1264
|
|
4
|
+
rayon_sdk_py/models.py,sha256=b7WBlRkzqZ1Qdckw493GkLNkLDyu9Pqnv_RF3QgXmAM,467
|
|
5
|
+
rayon_sdk_py/validation.py,sha256=vSZNUuTaPjyPd0QaoSlq9ZPktQ35IiPuWF7JYB3Ks-4,6999
|
|
6
|
+
rayon_sdk_py-0.1.0.dist-info/licenses/LICENSE,sha256=ZpwTpWfU2CEge2rhxDciRXkJlbzTCrWgKA75VTgfHsQ,1065
|
|
7
|
+
rayon_sdk_py-0.1.0.dist-info/METADATA,sha256=Wdxwy-aomnUR8aWDJfxX7tXKQurjyDcPnVoW-PZ7iB8,4821
|
|
8
|
+
rayon_sdk_py-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
rayon_sdk_py-0.1.0.dist-info/top_level.txt,sha256=2eDfZYL46oPSnsHv430ZRt5iyKl2zAk2ilIemakP05U,13
|
|
10
|
+
rayon_sdk_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RayonAPI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rayon_sdk_py
|