runapi-core 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.
runapi/core/errors.py ADDED
@@ -0,0 +1,254 @@
1
+ """Error hierarchy and HTTP-response-to-error mapping for the RunAPI SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Any, Dict, Optional
10
+
11
+ _HTML_MARKER = re.compile(r"<!doctype|<html", re.IGNORECASE)
12
+ _TITLE = re.compile(r"<title>(.*?)</title>", re.IGNORECASE | re.DOTALL)
13
+ _H1 = re.compile(r"<h1>(.*?)</h1>", re.IGNORECASE | re.DOTALL)
14
+ _ENTITY = re.compile(r"&[a-z]+;", re.IGNORECASE)
15
+ _TAG = re.compile(r"<[^>]+>")
16
+
17
+
18
+ class Error(Exception):
19
+ """Base error for all RunAPI SDK failures.
20
+
21
+ Carries the HTTP status, request id, and parsed response details when
22
+ available.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ message: Optional[str] = None,
28
+ *,
29
+ status: Optional[int] = None,
30
+ request_id: Optional[str] = None,
31
+ details: Any = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.status = status
36
+ self.request_id = request_id
37
+ self.details = details
38
+
39
+ def to_dict(self) -> Dict[str, Any]:
40
+ data = {
41
+ "error": type(self).__name__,
42
+ "message": self.message,
43
+ "status": self.status,
44
+ "request_id": self.request_id,
45
+ "details": self.details,
46
+ }
47
+ return {key: value for key, value in data.items() if value is not None}
48
+
49
+
50
+ class AuthenticationError(Error):
51
+ """API key is missing or invalid (HTTP 401)."""
52
+
53
+ def __init__(self, message: str = "Unauthorized", *, status: int = 401, **kwargs: Any) -> None:
54
+ super().__init__(message, status=status, **kwargs)
55
+
56
+
57
+ class RateLimitError(Error):
58
+ """Rate limit exceeded (HTTP 429). Includes the retry-after delay."""
59
+
60
+ def __init__(
61
+ self,
62
+ message: str = "Too many requests",
63
+ *,
64
+ status: int = 429,
65
+ retry_after: Optional[float] = None,
66
+ **kwargs: Any,
67
+ ) -> None:
68
+ super().__init__(message, status=status, **kwargs)
69
+ self.retry_after = retry_after
70
+
71
+
72
+ class InsufficientCreditsError(Error):
73
+ """Account has insufficient credits (HTTP 402)."""
74
+
75
+ def __init__(self, message: str = "Insufficient credits", *, status: int = 402, **kwargs: Any) -> None:
76
+ super().__init__(message, status=status, **kwargs)
77
+
78
+
79
+ class NotFoundError(Error):
80
+ """Requested resource does not exist (HTTP 404)."""
81
+
82
+ def __init__(self, message: str = "Not found", *, status: int = 404, **kwargs: Any) -> None:
83
+ super().__init__(message, status=status, **kwargs)
84
+
85
+
86
+ class ValidationError(Error):
87
+ """Request validation failed (HTTP 400 / 422), or a client-side check failed."""
88
+
89
+ def __init__(self, message: str = "Validation failed", **kwargs: Any) -> None:
90
+ super().__init__(message, **kwargs)
91
+
92
+
93
+ class ServiceUnavailableError(Error):
94
+ """Service is temporarily unavailable (HTTP 503)."""
95
+
96
+ def __init__(self, message: str = "Service unavailable", *, status: Optional[int] = None, **kwargs: Any) -> None:
97
+ super().__init__(message, status=503 if status is None else status, **kwargs)
98
+
99
+
100
+ class ConflictError(Error):
101
+ """Request conflicts with the current resource state (HTTP 409)."""
102
+
103
+ def __init__(self, message: str = "Conflict", *, status: int = 409, **kwargs: Any) -> None:
104
+ super().__init__(message, status=status, **kwargs)
105
+
106
+
107
+ class ServerError(Error):
108
+ """Server encountered an internal error (HTTP 5xx)."""
109
+
110
+ def __init__(self, message: str = "Server error", *, status: Optional[int] = None, **kwargs: Any) -> None:
111
+ super().__init__(message, status=500 if status is None else status, **kwargs)
112
+
113
+
114
+ class NetworkError(Error):
115
+ """Network connection failed or the request could not be sent."""
116
+
117
+ def __init__(self, message: str = "Network error", **kwargs: Any) -> None:
118
+ super().__init__(message, **kwargs)
119
+
120
+
121
+ class TimeoutError(Error): # noqa: A001 - intentional SDK error name, parallels other SDKs
122
+ """HTTP request exceeded the configured timeout."""
123
+
124
+ def __init__(self, message: str = "Request timed out", **kwargs: Any) -> None:
125
+ super().__init__(message, **kwargs)
126
+
127
+
128
+ class TaskTimeoutError(Error):
129
+ """Polling for task completion exceeded the maximum wait time."""
130
+
131
+ def __init__(self, message: str = "Task polling timed out", **kwargs: Any) -> None:
132
+ super().__init__(message, **kwargs)
133
+
134
+
135
+ class TaskFailedError(Error):
136
+ """Async task failed during processing."""
137
+
138
+ def __init__(self, message: str = "Task failed", **kwargs: Any) -> None:
139
+ super().__init__(message, **kwargs)
140
+
141
+
142
+ STATUS_MAP = {
143
+ 400: ValidationError,
144
+ 401: AuthenticationError,
145
+ 402: InsufficientCreditsError,
146
+ 404: NotFoundError,
147
+ 409: ConflictError,
148
+ 422: ValidationError,
149
+ 429: RateLimitError,
150
+ 500: ServerError,
151
+ 501: ServerError,
152
+ 502: ServerError,
153
+ 503: ServiceUnavailableError,
154
+ 504: ServerError,
155
+ 505: ServerError,
156
+ }
157
+
158
+ DEFAULT_MESSAGES = {
159
+ 400: "Bad request",
160
+ 401: "Unauthorized",
161
+ 402: "Insufficient credits",
162
+ 404: "Not found",
163
+ 408: "Request timeout",
164
+ 409: "Conflict",
165
+ 413: "Payload too large",
166
+ 415: "Unsupported media type",
167
+ 422: "Validation failed",
168
+ 429: "Too many requests",
169
+ 503: "Service unavailable",
170
+ }
171
+
172
+
173
+ def error_from_response(response: "Any") -> Error:
174
+ """Build the appropriate error from an ``httpx.Response``.
175
+
176
+ Maps the status code to a specific error class and extracts the message,
177
+ request id, response details, and (for 429) the retry-after delay.
178
+ """
179
+ status = response.status_code
180
+ request_id = response.headers.get("x-request-id")
181
+
182
+ parsed_body = _parse_body(response.text)
183
+ message = _extract_message(parsed_body) or DEFAULT_MESSAGES.get(status) or "Request failed"
184
+
185
+ error_class = STATUS_MAP.get(status, Error)
186
+
187
+ kwargs: Dict[str, Any] = {"status": status, "request_id": request_id, "details": parsed_body}
188
+ if error_class is RateLimitError:
189
+ kwargs["retry_after"] = _parse_retry_after(response.headers.get("retry-after"))
190
+
191
+ return error_class(message, **kwargs)
192
+
193
+
194
+ def _parse_body(body: Optional[str]) -> Any:
195
+ if not body:
196
+ return None
197
+ if _HTML_MARKER.search(body):
198
+ return _extract_html_error(body)
199
+ try:
200
+ return json.loads(body)
201
+ except ValueError:
202
+ return body
203
+
204
+
205
+ def _extract_html_error(html: str) -> Dict[str, Any]:
206
+ match = _TITLE.search(html) or _H1.search(html)
207
+ error_text = match.group(1) if match else "HTML Error Page"
208
+ error_text = _TAG.sub("", _ENTITY.sub(" ", error_text)).strip()
209
+ return {
210
+ "error": error_text,
211
+ "is_html_error": True,
212
+ "message": f"Server returned HTML error page: {error_text}",
213
+ }
214
+
215
+
216
+ def _extract_message(body: Any) -> Optional[str]:
217
+ if not isinstance(body, dict):
218
+ return None
219
+
220
+ error = body.get("error")
221
+ if isinstance(error, dict):
222
+ message = error.get("message")
223
+ else:
224
+ message = error
225
+
226
+ if message:
227
+ return message
228
+
229
+ errors = body.get("errors")
230
+ if isinstance(errors, list) and errors:
231
+ first = errors[0]
232
+ return first.get("message") if isinstance(first, dict) else first
233
+
234
+ return body.get("message") or body.get("detail") or body.get("errorMessage") or body.get("msg")
235
+
236
+
237
+ def _parse_retry_after(value: Optional[str]) -> Optional[float]:
238
+ if value is None:
239
+ return None
240
+
241
+ try:
242
+ return float(value)
243
+ except (TypeError, ValueError):
244
+ pass
245
+
246
+ try:
247
+ parsed = parsedate_to_datetime(value)
248
+ except (TypeError, ValueError):
249
+ return None
250
+ if parsed is None:
251
+ return None
252
+ if parsed.tzinfo is None:
253
+ parsed = parsed.replace(tzinfo=timezone.utc)
254
+ return (parsed - datetime.now(timezone.utc)).total_seconds()
runapi/core/files.py ADDED
@@ -0,0 +1,101 @@
1
+ """File upload client.
2
+
3
+ Shared infrastructure for every model line that takes file inputs: upload a
4
+ local file or register a remote URL and receive a usable file reference.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any, Optional
11
+
12
+ from . import auth
13
+ from .http_client import HttpClient
14
+ from .models import BaseModel, optional, required
15
+ from .multipart import MultipartBody, MultipartFile
16
+ from .options import ClientOptions, RequestOptions
17
+ from .resource import Resource
18
+
19
+
20
+ class UploadResponse(BaseModel):
21
+ file_name = required(str)
22
+ url = required(str)
23
+ size_bytes = required(int)
24
+ mime_type = required(str)
25
+ created_at = required(str)
26
+ expires_at = required(str)
27
+
28
+
29
+ class FilesClient(Resource):
30
+ ENDPOINT = "/api/v1/files"
31
+
32
+ RESPONSE_CLASS = UploadResponse
33
+
34
+ def __init__(self, api_key: Optional[str] = None, **options: Any) -> None:
35
+ resolved_api_key = auth.resolve_api_key(api_key)
36
+ client_options = ClientOptions(api_key=resolved_api_key, **options)
37
+ http = client_options.http_client or HttpClient(client_options)
38
+ super().__init__(http)
39
+
40
+ def create(
41
+ self,
42
+ file: Any = None,
43
+ source: Optional[str] = None,
44
+ file_name: Optional[str] = None,
45
+ options: Optional[RequestOptions] = None,
46
+ ) -> Any:
47
+ """Upload a local ``file`` (path or file-like) or register a remote ``source`` URL.
48
+
49
+ Exactly one of ``file`` or ``source`` is required.
50
+
51
+ Args:
52
+ file: Local file path or file-like object to upload.
53
+ source: Remote URL to register instead of uploading a local file.
54
+ file_name: Optional name to record for the uploaded file.
55
+ options: Optional per-request options.
56
+
57
+ Returns:
58
+ The upload response with the usable file reference.
59
+ """
60
+ self._validate_source(file, source)
61
+
62
+ if file is not None:
63
+ body: Any = self._multipart_body(file, file_name)
64
+ else:
65
+ body = self._compact_params({"source": source, "file_name": file_name})
66
+
67
+ return self._request("post", self.ENDPOINT, body=body, options=options)
68
+
69
+ @staticmethod
70
+ def _validate_source(file: Any, source: Optional[str]) -> None:
71
+ def present(value: Any) -> bool:
72
+ if value is None:
73
+ return False
74
+ if isinstance(value, str):
75
+ return value.strip() != ""
76
+ return True
77
+
78
+ provided = sum(1 for value in (file, source) if present(value))
79
+ if provided != 1:
80
+ raise ValueError("Exactly one source is required: file or source")
81
+
82
+ def _multipart_body(self, file: Any, file_name: Optional[str]) -> MultipartBody:
83
+ path = self._file_path(file)
84
+ filename = file_name or os.path.basename(path)
85
+ return MultipartBody(
86
+ fields=self._compact_params({"file_name": file_name}),
87
+ files={"file": MultipartFile(path=path, filename=filename)},
88
+ )
89
+
90
+ @staticmethod
91
+ def _file_path(file: Any) -> str:
92
+ if isinstance(file, str):
93
+ return file
94
+ # pathlib.Path (and any os.PathLike) must keep their full path; Path.name
95
+ # would return only the basename, breaking the later open().
96
+ if isinstance(file, os.PathLike):
97
+ return os.fspath(file)
98
+ name = getattr(file, "name", None)
99
+ if name:
100
+ return name
101
+ raise ValueError("file must be a file path or a file-like object with a name")
@@ -0,0 +1,130 @@
1
+ """HTTP transport built on ``httpx.Client`` with retries and multipart support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import time
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ import httpx
11
+
12
+ from . import constants
13
+ from .errors import NetworkError, RateLimitError, TimeoutError, error_from_response
14
+ from .multipart import MultipartBody
15
+ from .options import ClientOptions, RequestOptions
16
+
17
+ _NO_BODY = object()
18
+
19
+
20
+ class HttpClient:
21
+ """Synchronous HTTP client.
22
+
23
+ ``httpx.Client`` keeps connections alive across requests, so no explicit
24
+ connection pool is needed. Pass ``transport`` to inject an
25
+ ``httpx.MockTransport`` in tests.
26
+ """
27
+
28
+ def __init__(self, options: ClientOptions, *, transport: Optional[httpx.BaseTransport] = None) -> None:
29
+ self._options = options
30
+ self._client = httpx.Client(
31
+ base_url=options.base_url,
32
+ timeout=options.timeout,
33
+ transport=transport,
34
+ headers={
35
+ "Authorization": f"Bearer {options.api_key}",
36
+ "Accept": "application/json",
37
+ "User-Agent": constants.SDK_USER_AGENT,
38
+ },
39
+ )
40
+
41
+ def request(
42
+ self,
43
+ method: str,
44
+ path: str,
45
+ body: Any = None,
46
+ options: Optional[RequestOptions] = None,
47
+ ) -> Any:
48
+ max_retries = self._options.max_retries
49
+ if options is not None and options.max_retries is not None:
50
+ max_retries = options.max_retries
51
+
52
+ headers = {str(key): str(value) for key, value in (options.headers or {}).items()} if options else {}
53
+ timeout = options.timeout if (options and options.timeout is not None) else httpx.USE_CLIENT_DEFAULT
54
+
55
+ json_body, data, files, opened = self._build_payload(body)
56
+ method = method.upper()
57
+ retries = 0
58
+
59
+ try:
60
+ while True:
61
+ try:
62
+ response = self._client.request(
63
+ method,
64
+ path,
65
+ json=json_body,
66
+ data=data,
67
+ files=files,
68
+ headers=headers,
69
+ timeout=timeout,
70
+ )
71
+ except httpx.TimeoutException as exc:
72
+ raise TimeoutError(str(exc))
73
+ except httpx.TransportError as exc:
74
+ raise NetworkError(str(exc))
75
+
76
+ if response.is_success:
77
+ return self._parse_body(response.text)
78
+
79
+ error = error_from_response(response)
80
+ if self._retryable(method, response.status_code) and retries < max_retries:
81
+ retries += 1
82
+ time.sleep(self._retry_delay(retries, error))
83
+ continue
84
+
85
+ raise error
86
+ finally:
87
+ for handle in opened:
88
+ handle.close()
89
+
90
+ def close(self) -> None:
91
+ self._client.close()
92
+
93
+ def _build_payload(
94
+ self, body: Any
95
+ ) -> Tuple[Any, Optional[Dict[str, Any]], Optional[Dict[str, Any]], List[Any]]:
96
+ if isinstance(body, MultipartBody):
97
+ data = {key: str(value) for key, value in body.fields.items()}
98
+ files: Dict[str, Any] = {}
99
+ opened: List[Any] = []
100
+ for key, part in body.files.items():
101
+ handle = open(part.path, "rb")
102
+ opened.append(handle)
103
+ filename = part.filename
104
+ if part.content_type:
105
+ files[key] = (filename, handle, part.content_type)
106
+ else:
107
+ files[key] = (filename, handle)
108
+ return None, data, files, opened
109
+ if body is not None:
110
+ return body, None, None, []
111
+ return None, None, None, []
112
+
113
+ def _retryable(self, method: str, status: int) -> bool:
114
+ return method in constants.IDEMPOTENT_METHODS and status in constants.RETRYABLE_STATUS_CODES
115
+
116
+ def _retry_delay(self, attempt: int, error: Any) -> float:
117
+ if isinstance(error, RateLimitError) and error.retry_after and error.retry_after > 0:
118
+ return error.retry_after
119
+ base = self._options.retry_base_delay * (2 ** (attempt - 1))
120
+ jitter = random.random() * base * 0.5
121
+ return min(base + jitter, self._options.retry_max_delay)
122
+
123
+ @staticmethod
124
+ def _parse_body(body: Optional[str]) -> Any:
125
+ if not body:
126
+ return None
127
+ try:
128
+ return json.loads(body)
129
+ except ValueError:
130
+ return body