runnee 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.
runnee/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Official Runnee Cloud SDK. Scripts execute remotely on Runnee infrastructure."""
2
+
3
+ from .clients import Runnee, AsyncRunnee
4
+ from ._http import RequestOptions, ResponseMetadata
5
+ from .errors import (
6
+ RunneeError,
7
+ RunneeAuthenticationError,
8
+ RunneePermissionError,
9
+ RunneeValidationError,
10
+ RunneeRateLimitError,
11
+ RunneeTimeoutError,
12
+ RunneeNetworkError,
13
+ RunneeServerError,
14
+ RunneeConflictError,
15
+ RunneeNotFoundError,
16
+ RunneeResponseError,
17
+ RunneeCancelledError,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = [
22
+ "Runnee",
23
+ "AsyncRunnee",
24
+ "RequestOptions",
25
+ "ResponseMetadata",
26
+ "RunneeError",
27
+ "RunneeAuthenticationError",
28
+ "RunneePermissionError",
29
+ "RunneeValidationError",
30
+ "RunneeRateLimitError",
31
+ "RunneeTimeoutError",
32
+ "RunneeNetworkError",
33
+ "RunneeServerError",
34
+ "RunneeConflictError",
35
+ "RunneeNotFoundError",
36
+ "RunneeResponseError",
37
+ "RunneeCancelledError",
38
+ ]
runnee/_http.py ADDED
@@ -0,0 +1,375 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import json
4
+ import math
5
+ import os
6
+ import random
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from email.utils import parsedate_to_datetime
11
+ from typing import Any, Callable
12
+ from urllib.parse import quote, urlsplit
13
+ import httpx
14
+ from ._validation import CONTRACTS, validate
15
+ from .errors import (
16
+ RunneeAuthenticationError,
17
+ RunneeCancelledError,
18
+ RunneeConflictError,
19
+ RunneeError,
20
+ RunneeNetworkError,
21
+ RunneeNotFoundError,
22
+ RunneePermissionError,
23
+ RunneeRateLimitError,
24
+ RunneeResponseError,
25
+ RunneeServerError,
26
+ RunneeTimeoutError,
27
+ RunneeValidationError,
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ResponseMetadata:
33
+ status: int
34
+ request_id: str | None
35
+ rate_limit_remaining: int | None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RequestOptions:
40
+ timeout: float | None = None
41
+ max_retries: int | None = None
42
+ idempotency_key: str | None = None
43
+ cancel_event: threading.Event | None = None
44
+ on_response: Callable[[ResponseMetadata], None] | None = None
45
+
46
+
47
+ def _range(value: float, low: float, high: float, name: str) -> float:
48
+ if (
49
+ not isinstance(value, (int, float))
50
+ or isinstance(value, bool)
51
+ or not math.isfinite(value)
52
+ or not low <= value <= high
53
+ ):
54
+ raise RunneeValidationError(name + " is outside the supported range.")
55
+ return value
56
+
57
+
58
+ def _retry_after(value: str | None) -> float | None:
59
+ if not value:
60
+ return None
61
+ try:
62
+ result = float(value)
63
+ if math.isfinite(result) and result >= 0:
64
+ return result
65
+ except ValueError:
66
+ pass
67
+ try:
68
+ return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
69
+ except (ValueError, TypeError, OverflowError):
70
+ return None
71
+
72
+
73
+ class _Base:
74
+ def __init__(
75
+ self,
76
+ *,
77
+ api_key: str | None = None,
78
+ base_url: str | None = None,
79
+ timeout: float = 30,
80
+ max_retries: int = 2,
81
+ allow_insecure_http: bool = False,
82
+ ) -> None:
83
+ import re
84
+
85
+ key = api_key if api_key is not None else os.environ.get("RUNNEE_API_KEY")
86
+ if not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9_-]{8,512}", key):
87
+ raise RunneeAuthenticationError("Set RUNNEE_API_KEY or provide api_key.")
88
+ raw_url = base_url or os.environ.get("RUNNEE_BASE_URL", "https://api.runnee.cloud")
89
+ try:
90
+ if not isinstance(raw_url, str):
91
+ raise ValueError
92
+ url = urlsplit(raw_url)
93
+ if (
94
+ not url.hostname
95
+ or url.username
96
+ or url.password
97
+ or url.query
98
+ or url.fragment
99
+ or url.path not in ("", "/")
100
+ or url.scheme not in ("https", "http")
101
+ or (
102
+ url.scheme == "http"
103
+ and url.hostname not in ("localhost", "127.0.0.1", "::1")
104
+ and not allow_insecure_http
105
+ )
106
+ ):
107
+ raise ValueError
108
+ self._base_url = raw_url.rstrip("/")
109
+ except ValueError:
110
+ raise RunneeValidationError(
111
+ "base_url requires an HTTPS origin; HTTP is limited to local development."
112
+ ) from None
113
+ self._key = key
114
+ self._timeout = _range(timeout, 0.001, 300, "timeout")
115
+ _range(max_retries, 0, 5, "max_retries")
116
+ if not isinstance(max_retries, int):
117
+ raise RunneeValidationError("max_retries must be an integer.")
118
+ self._retries = max_retries
119
+
120
+ def _prepare(
121
+ self, operation: str, params: dict[str, Any], body: Any, options: RequestOptions
122
+ ) -> tuple[dict[str, Any], str, dict[str, str], int, float]:
123
+ import re
124
+
125
+ op = next((item for item in CONTRACTS["operations"] if item["id"] == operation), None)
126
+ if not op:
127
+ raise RunneeValidationError("Unsupported SDK operation.")
128
+ pathname = op["path"]
129
+ query: dict[str, str] = {}
130
+ for key, value in params.items():
131
+ if value is None:
132
+ continue
133
+ marker = "{" + key + "}"
134
+ if marker in pathname:
135
+ text = str(value)
136
+ if not text or text in (".", "..") or re.search(r"[/\\\x00-\x1f]", text):
137
+ raise RunneeValidationError("Invalid resource identifier.")
138
+ pathname = pathname.replace(marker, quote(text, safe=""))
139
+ else:
140
+ query[key] = str(value)
141
+ if "{" in pathname:
142
+ raise RunneeValidationError("Missing resource identifier.")
143
+ if op["request"]:
144
+ validate(body, op["request"])
145
+ try:
146
+ encoded = json.dumps(body, allow_nan=False, ensure_ascii=False)
147
+ if len(encoded.encode("utf-8")) > 102400:
148
+ raise ValueError
149
+ except (ValueError, TypeError, RecursionError, UnicodeError):
150
+ raise RunneeValidationError("Request data must be bounded, serializable JSON.") from None
151
+ if options.idempotency_key is not None and (
152
+ operation != "execute" or not re.fullmatch(r"[A-Za-z0-9_.:-]{8,128}", options.idempotency_key)
153
+ ):
154
+ raise RunneeValidationError("Use an 8–128 character idempotency_key only for execute.")
155
+ safe = op["method"] == "GET" or operation == "execute" and bool(options.idempotency_key)
156
+ retries = options.max_retries if options.max_retries is not None else self._retries if safe else 0
157
+ _range(retries, 0, 5, "max_retries")
158
+ if not isinstance(retries, int) or retries and not safe:
159
+ raise RunneeValidationError("Automatic retries require reads or idempotent execution.")
160
+ timeout = _range(
161
+ options.timeout if options.timeout is not None else self._timeout, 0.001, 300, "timeout"
162
+ )
163
+ from urllib.parse import urlencode
164
+
165
+ return (
166
+ op,
167
+ self._base_url + pathname + ("?" + urlencode(query) if query else ""),
168
+ query,
169
+ retries,
170
+ timeout,
171
+ )
172
+
173
+ def _headers(self, options: RequestOptions) -> dict[str, str]:
174
+ headers = {
175
+ "Authorization": "Bearer " + self._key,
176
+ "Accept": "application/json",
177
+ "User-Agent": "runnee-python/0.1.0",
178
+ }
179
+ if options.idempotency_key:
180
+ headers["Idempotency-Key"] = options.idempotency_key
181
+ return headers
182
+
183
+ @staticmethod
184
+ def _check(deadline: float, options: RequestOptions) -> float:
185
+ if options.cancel_event and options.cancel_event.is_set():
186
+ raise RunneeCancelledError("Request cancelled. Remote execution may still complete.")
187
+ left = deadline - time.monotonic()
188
+ if left <= 0:
189
+ raise RunneeTimeoutError(
190
+ "Request timed out. Reuse its idempotency key or inspect history before submitting again."
191
+ )
192
+ return left
193
+
194
+ @staticmethod
195
+ def _response(response: httpx.Response, options: RequestOptions) -> None:
196
+ if options.on_response:
197
+ remaining = response.headers.get("x-ratelimit-remaining", "")
198
+ options.on_response(
199
+ ResponseMetadata(
200
+ response.status_code,
201
+ response.headers.get("x-request-id"),
202
+ int(remaining) if remaining.isdigit() else None,
203
+ )
204
+ )
205
+
206
+ @staticmethod
207
+ def _decode(response: httpx.Response, data: bytes, schema: str) -> Any:
208
+ request_id = response.headers.get("x-request-id")
209
+ if not 200 <= response.status_code < 300:
210
+ classes: dict[int, type[RunneeError]] = {
211
+ 401: RunneeAuthenticationError,
212
+ 403: RunneePermissionError,
213
+ 404: RunneeNotFoundError,
214
+ 409: RunneeConflictError,
215
+ 429: RunneeRateLimitError,
216
+ }
217
+ error_cls = classes.get(
218
+ response.status_code,
219
+ RunneeServerError if response.status_code >= 500 else RunneeValidationError,
220
+ )
221
+ message = (
222
+ "Request conflicts with existing state. An execution may already be admitted; inspect history."
223
+ if response.status_code == 409
224
+ else "Invalid, expired or revoked Runnee API key."
225
+ if response.status_code == 401
226
+ else "The API key scope or resource policy does not permit this operation."
227
+ if response.status_code == 403
228
+ else f"Runnee API request failed (HTTP {response.status_code})."
229
+ )
230
+ error = error_cls(message, status=response.status_code, request_id=request_id)
231
+ if isinstance(error, RunneeRateLimitError):
232
+ error.retry_after = _retry_after(response.headers.get("retry-after"))
233
+ raise error
234
+ content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
235
+ if content_type != "application/json" and not content_type.endswith("+json"):
236
+ raise RunneeResponseError(
237
+ "Runnee returned a non-JSON response.", status=response.status_code, request_id=request_id
238
+ )
239
+ try:
240
+ value = json.loads(data)
241
+ except (ValueError, UnicodeError):
242
+ raise RunneeResponseError(
243
+ "Runnee returned invalid JSON.", status=response.status_code, request_id=request_id
244
+ ) from None
245
+ validate(value, schema, True, request_id)
246
+ return value
247
+
248
+ @staticmethod
249
+ def _delay(status: int, headers: httpx.Headers, attempt: int, retries: int) -> float | None:
250
+ after = _retry_after(headers.get("retry-after"))
251
+ if status in (429, 500, 502, 503, 504) and attempt < retries and (after is None or after <= 60):
252
+ return after if after is not None else min(10, 0.25 * 2**attempt) * random.uniform(0.5, 1)
253
+ return None
254
+
255
+
256
+ class SyncHTTP(_Base):
257
+ def __init__(self, *, transport: httpx.BaseTransport | None = None, **kwargs: Any) -> None:
258
+ super().__init__(**kwargs)
259
+ self._client = httpx.Client(transport=transport, follow_redirects=False, trust_env=False)
260
+
261
+ def close(self) -> None:
262
+ self._client.close()
263
+
264
+ def _wait(self, delay: float, deadline: float, options: RequestOptions) -> None:
265
+ left = self._check(deadline, options)
266
+ duration = min(delay, left)
267
+ if options.cancel_event:
268
+ options.cancel_event.wait(duration)
269
+ else:
270
+ time.sleep(duration)
271
+ self._check(deadline, options)
272
+
273
+ def request(
274
+ self, operation: str, params: dict[str, Any], body: Any, options: RequestOptions | None = None
275
+ ) -> Any:
276
+ options = options or RequestOptions()
277
+ op, url, _, retries, timeout = self._prepare(operation, params, body, options)
278
+ deadline = time.monotonic() + timeout
279
+ for attempt in range(retries + 1):
280
+ delay: float | None = None
281
+ try:
282
+ with self._client.stream(
283
+ op["method"],
284
+ url,
285
+ headers=self._headers(options),
286
+ json=body,
287
+ timeout=self._check(deadline, options),
288
+ ) as response:
289
+ self._response(response, options)
290
+ delay = self._delay(response.status_code, response.headers, attempt, retries)
291
+ if delay is None:
292
+ chunks: list[bytes] = []
293
+ size = 0
294
+ if response.is_success:
295
+ for chunk in response.iter_bytes():
296
+ self._check(deadline, options)
297
+ size += len(chunk)
298
+ if size > 5 * 1024 * 1024:
299
+ raise RunneeResponseError("Runnee response exceeds the SDK size limit.")
300
+ chunks.append(chunk)
301
+ return self._decode(response, b"".join(chunks), op["response"])
302
+ except httpx.TimeoutException:
303
+ raise RunneeTimeoutError(
304
+ "Request timed out. An execution may still have completed."
305
+ ) from None
306
+ except httpx.HTTPError:
307
+ if attempt >= retries:
308
+ raise RunneeNetworkError(
309
+ "Unable to reach Runnee. Inspect history before resubmitting an uncertain execution."
310
+ ) from None
311
+ delay = min(10, 0.25 * 2**attempt) * random.uniform(0.5, 1)
312
+ if delay is not None:
313
+ self._wait(delay, deadline, options)
314
+ raise RunneeNetworkError("Runnee request could not be completed.")
315
+
316
+
317
+ class AsyncHTTP(_Base):
318
+ def __init__(self, *, transport: httpx.AsyncBaseTransport | None = None, **kwargs: Any) -> None:
319
+ super().__init__(**kwargs)
320
+ self._client = httpx.AsyncClient(transport=transport, follow_redirects=False, trust_env=False)
321
+
322
+ async def close(self) -> None:
323
+ await self._client.aclose()
324
+
325
+ async def request(
326
+ self, operation: str, params: dict[str, Any], body: Any, options: RequestOptions | None = None
327
+ ) -> Any:
328
+ options = options or RequestOptions()
329
+ op, url, _, retries, timeout = self._prepare(operation, params, body, options)
330
+ deadline = time.monotonic() + timeout
331
+ try:
332
+ async with asyncio.timeout(timeout):
333
+ for attempt in range(retries + 1):
334
+ delay: float | None = None
335
+ try:
336
+ async with self._client.stream(
337
+ op["method"],
338
+ url,
339
+ headers=self._headers(options),
340
+ json=body,
341
+ timeout=self._check(deadline, options),
342
+ ) as response:
343
+ self._response(response, options)
344
+ delay = self._delay(response.status_code, response.headers, attempt, retries)
345
+ if delay is None:
346
+ chunks: list[bytes] = []
347
+ size = 0
348
+ if response.is_success:
349
+ async for chunk in response.aiter_bytes():
350
+ self._check(deadline, options)
351
+ size += len(chunk)
352
+ if size > 5 * 1024 * 1024:
353
+ raise RunneeResponseError(
354
+ "Runnee response exceeds the SDK size limit."
355
+ )
356
+ chunks.append(chunk)
357
+ return self._decode(response, b"".join(chunks), op["response"])
358
+ except httpx.TimeoutException:
359
+ raise RunneeTimeoutError(
360
+ "Request timed out. An execution may still have completed."
361
+ ) from None
362
+ except httpx.HTTPError:
363
+ if attempt >= retries:
364
+ raise RunneeNetworkError(
365
+ "Unable to reach Runnee. Inspect history before resubmitting an uncertain execution."
366
+ ) from None
367
+ delay = min(10, 0.25 * 2**attempt) * random.uniform(0.5, 1)
368
+ if delay is not None:
369
+ self._check(deadline, options)
370
+ await asyncio.sleep(delay)
371
+ raise RunneeNetworkError("Runnee request could not be completed.")
372
+ except TimeoutError:
373
+ raise RunneeTimeoutError(
374
+ "Request timed out. Reuse its idempotency key or inspect history."
375
+ ) from None
runnee/_validation.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import math
4
+ import re
5
+ from importlib.resources import files
6
+ from typing import Any
7
+ from .errors import RunneeResponseError, RunneeValidationError
8
+
9
+ CONTRACTS: dict[str, Any] = json.loads(files("runnee").joinpath("contracts.json").read_text(encoding="utf-8"))
10
+
11
+
12
+ def valid(value: Any, schema: dict[str, Any], depth: int = 0) -> bool:
13
+ if depth > 40:
14
+ return False
15
+ if "$ref" in schema:
16
+ return valid(value, CONTRACTS["schemas"][schema["$ref"].split("/")[-1]], depth + 1)
17
+ if "anyOf" in schema:
18
+ return any(valid(value, item, depth + 1) for item in schema["anyOf"])
19
+ if "enum" in schema and value not in schema["enum"]:
20
+ return False
21
+ kind = schema.get("type")
22
+ if kind == "null":
23
+ return value is None
24
+ if kind == "boolean":
25
+ return isinstance(value, bool)
26
+ if kind in ("number", "integer"):
27
+ return (
28
+ isinstance(value, (int, float))
29
+ and not isinstance(value, bool)
30
+ and math.isfinite(value)
31
+ and (kind != "integer" or isinstance(value, int))
32
+ and value >= schema.get("minimum", -math.inf)
33
+ and value <= schema.get("maximum", math.inf)
34
+ )
35
+ if kind == "string":
36
+ return (
37
+ isinstance(value, str)
38
+ and len(value) >= schema.get("minLength", 0)
39
+ and len(value) <= schema.get("maxLength", math.inf)
40
+ and ("pattern" not in schema or re.search(schema["pattern"], value) is not None)
41
+ and (
42
+ schema.get("format") != "uuid"
43
+ or re.fullmatch(r"[a-fA-F0-9]{8}-(?:[a-fA-F0-9]{4}-){3}[a-fA-F0-9]{12}", value) is not None
44
+ )
45
+ )
46
+ if kind == "array":
47
+ return (
48
+ isinstance(value, list)
49
+ and len(value) >= schema.get("minItems", 0)
50
+ and len(value) <= schema.get("maxItems", math.inf)
51
+ and (
52
+ not schema.get("uniqueItems")
53
+ or len({json.dumps(v, sort_keys=True) for v in value}) == len(value)
54
+ )
55
+ and all(valid(item, schema.get("items", {}), depth + 1) for item in value)
56
+ )
57
+ if kind == "object":
58
+ if not isinstance(value, dict) or len(value) < schema.get("minProperties", 0):
59
+ return False
60
+ if not all(key in value for key in schema.get("required", [])):
61
+ return False
62
+ for key, item in value.items():
63
+ if key in schema.get("properties", {}):
64
+ if not valid(item, schema["properties"][key], depth + 1):
65
+ return False
66
+ elif schema.get("additionalProperties") is False:
67
+ return False
68
+ elif isinstance(schema.get("additionalProperties"), dict):
69
+ if not valid(item, schema["additionalProperties"], depth + 1):
70
+ return False
71
+ return True
72
+
73
+
74
+ def validate(value: Any, name: str, response: bool = False, request_id: str | None = None) -> None:
75
+ if not valid(value, CONTRACTS["schemas"][name]):
76
+ if response:
77
+ raise RunneeResponseError("Runnee returned an unexpected response.", request_id=request_id)
78
+ raise RunneeValidationError("Request does not match the Runnee API contract.")