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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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