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 +38 -0
- runnee/_http.py +375 -0
- runnee/_validation.py +78 -0
- runnee/clients.py +579 -0
- runnee/contracts.json +1547 -0
- runnee/errors.py +54 -0
- runnee/py.typed +0 -0
- runnee/types.py +273 -0
- runnee-0.1.0.dist-info/METADATA +113 -0
- runnee-0.1.0.dist-info/RECORD +12 -0
- runnee-0.1.0.dist-info/WHEEL +4 -0
- runnee-0.1.0.dist-info/licenses/LICENSE +21 -0
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.")
|