runapi-core 0.1.0__tar.gz → 0.1.2__tar.gz
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-0.1.0 → runapi_core-0.1.2}/PKG-INFO +33 -2
- runapi_core-0.1.2/README.md +64 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/pyproject.toml +3 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/__init__.py +3 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/contract_gen.py +28 -8
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/errors.py +19 -1
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/files.py +52 -15
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/http_client.py +25 -1
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/models.py +18 -1
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/polling.py +19 -3
- runapi_core-0.1.2/src/runapi/core/resource.py +214 -0
- runapi_core-0.1.2/src/runapi/core/response.py +56 -0
- runapi_core-0.1.2/src/runapi/core/version.py +1 -0
- runapi_core-0.1.2/tests/test_files.py +170 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_http_client.py +50 -1
- {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_models.py +7 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_polling.py +24 -0
- runapi_core-0.1.2/tests/test_resource.py +125 -0
- runapi_core-0.1.0/README.md +0 -33
- runapi_core-0.1.0/src/runapi/core/resource.py +0 -65
- runapi_core-0.1.0/src/runapi/core/version.py +0 -1
- runapi_core-0.1.0/tests/test_files.py +0 -113
- runapi_core-0.1.0/tests/test_resource.py +0 -55
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/auth.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/config.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/options.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_auth.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_config.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.2}/tests/test_errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runapi-core
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Core module for the RunAPI Python SDK
|
|
5
5
|
Project-URL: Homepage, https://runapi.ai
|
|
6
6
|
Project-URL: Documentation, https://runapi.ai/docs#sdk
|
|
@@ -23,7 +23,7 @@ pip install runapi-core
|
|
|
23
23
|
|
|
24
24
|
## Notes
|
|
25
25
|
|
|
26
|
-
Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that
|
|
26
|
+
Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that Provider Clients share. Configure it globally or per client:
|
|
27
27
|
|
|
28
28
|
```python
|
|
29
29
|
import runapi.core as runapi
|
|
@@ -31,11 +31,42 @@ import runapi.core as runapi
|
|
|
31
31
|
runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## Request identifiers
|
|
35
|
+
|
|
36
|
+
RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
|
|
37
|
+
|
|
38
|
+
High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import os
|
|
42
|
+
|
|
43
|
+
from runapi.core import RequestOptions
|
|
44
|
+
from runapi.suno import SunoClient
|
|
45
|
+
|
|
46
|
+
client = SunoClient(api_key=os.environ["RUNAPI_API_KEY"])
|
|
47
|
+
options = RequestOptions(
|
|
48
|
+
headers={"X-Client-Request-Id": "order-123"},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
response = client.text_to_music.create(
|
|
52
|
+
prompt="A chill lo-fi beat",
|
|
53
|
+
model="suno-v4.5-plus",
|
|
54
|
+
vocal_mode="instrumental",
|
|
55
|
+
options=options,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
runapi_task_id = response.runapi_task_id
|
|
59
|
+
# Equivalent case-insensitive lookup:
|
|
60
|
+
runapi_task_id = response.response_headers["X-RunAPI-Task-Id"]
|
|
61
|
+
```
|
|
62
|
+
|
|
34
63
|
```python
|
|
35
64
|
from runapi.core import FilesClient
|
|
36
65
|
|
|
37
66
|
files = FilesClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
38
67
|
uploaded = files.create(file="./input.png")
|
|
68
|
+
remote = files.create(source="https://cdn.runapi.ai/public/samples/input.png")
|
|
69
|
+
inline = files.create(source="iVBORw0KGgo...")
|
|
39
70
|
print(uploaded.url)
|
|
40
71
|
```
|
|
41
72
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# RunAPI Core Python SDK
|
|
2
|
+
|
|
3
|
+
The RunAPI Core Python SDK provides shared authentication, HTTP, retry, error, and polling primitives for RunAPI model packages. Install `runapi-core` only when you are building SDK infrastructure or shared Python tooling; application code should normally install a concrete model package such as `runapi-flux-2`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install runapi-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Notes
|
|
12
|
+
|
|
13
|
+
Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that Provider Clients share. Configure it globally or per client:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import runapi.core as runapi
|
|
17
|
+
|
|
18
|
+
runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Request identifiers
|
|
22
|
+
|
|
23
|
+
RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
|
|
24
|
+
|
|
25
|
+
High-level Python Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
from runapi.core import RequestOptions
|
|
31
|
+
from runapi.suno import SunoClient
|
|
32
|
+
|
|
33
|
+
client = SunoClient(api_key=os.environ["RUNAPI_API_KEY"])
|
|
34
|
+
options = RequestOptions(
|
|
35
|
+
headers={"X-Client-Request-Id": "order-123"},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
response = client.text_to_music.create(
|
|
39
|
+
prompt="A chill lo-fi beat",
|
|
40
|
+
model="suno-v4.5-plus",
|
|
41
|
+
vocal_mode="instrumental",
|
|
42
|
+
options=options,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
runapi_task_id = response.runapi_task_id
|
|
46
|
+
# Equivalent case-insensitive lookup:
|
|
47
|
+
runapi_task_id = response.response_headers["X-RunAPI-Task-Id"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from runapi.core import FilesClient
|
|
52
|
+
|
|
53
|
+
files = FilesClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
54
|
+
uploaded = files.create(file="./input.png")
|
|
55
|
+
remote = files.create(source="https://cdn.runapi.ai/public/samples/input.png")
|
|
56
|
+
inline = files.create(source="iVBORw0KGgo...")
|
|
57
|
+
print(uploaded.url)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
Licensed under the Apache License, Version 2.0.
|
|
@@ -29,6 +29,7 @@ from .http_client import HttpClient
|
|
|
29
29
|
from .models import BaseModel, DynamicModel, TaskResponse, optional, required
|
|
30
30
|
from .multipart import MultipartBody, MultipartFile
|
|
31
31
|
from .options import ClientOptions, PollingOptions, RequestOptions
|
|
32
|
+
from .response import ApiResponse, ResponseHeaders
|
|
32
33
|
from .resource import Resource
|
|
33
34
|
from .version import __version__
|
|
34
35
|
|
|
@@ -57,6 +58,8 @@ __all__ = [
|
|
|
57
58
|
"ClientOptions",
|
|
58
59
|
"RequestOptions",
|
|
59
60
|
"PollingOptions",
|
|
61
|
+
"ApiResponse",
|
|
62
|
+
"ResponseHeaders",
|
|
60
63
|
"BaseModel",
|
|
61
64
|
"DynamicModel",
|
|
62
65
|
"TaskResponse",
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
# Code generated by SdkContractGenerator. DO NOT EDIT.
|
|
2
|
-
|
|
3
1
|
CONTRACT = {
|
|
4
2
|
"elevenlabs/isolate-audio": {
|
|
5
3
|
"models": ["audio-isolation"],
|
|
@@ -148,6 +146,9 @@ CONTRACT = {
|
|
|
148
146
|
"models": ["gpt-image-2"],
|
|
149
147
|
"fields_by_model": {
|
|
150
148
|
"gpt-image-2": {
|
|
149
|
+
"aspect_ratio": {
|
|
150
|
+
"enum": ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "2:1", "1:2", "3:1", "1:3", "21:9", "9:21"]
|
|
151
|
+
},
|
|
151
152
|
"output_resolution": {
|
|
152
153
|
"enum": ["1k", "2k", "4k"]
|
|
153
154
|
}
|
|
@@ -158,6 +159,9 @@ CONTRACT = {
|
|
|
158
159
|
"models": ["gpt-image-2"],
|
|
159
160
|
"fields_by_model": {
|
|
160
161
|
"gpt-image-2": {
|
|
162
|
+
"aspect_ratio": {
|
|
163
|
+
"enum": ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "2:1", "1:2", "3:1", "1:3", "21:9", "9:21"]
|
|
164
|
+
},
|
|
161
165
|
"output_resolution": {
|
|
162
166
|
"enum": ["1k", "2k", "4k"]
|
|
163
167
|
}
|
|
@@ -167,13 +171,27 @@ CONTRACT = {
|
|
|
167
171
|
"gpt-image/edit-image": {
|
|
168
172
|
"models": ["gpt-image-1.5"],
|
|
169
173
|
"fields_by_model": {
|
|
170
|
-
"gpt-image-1.5": {
|
|
174
|
+
"gpt-image-1.5": {
|
|
175
|
+
"aspect_ratio": {
|
|
176
|
+
"enum": ["1:1", "2:3", "3:2"]
|
|
177
|
+
},
|
|
178
|
+
"quality": {
|
|
179
|
+
"enum": ["medium", "high"]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
171
182
|
}
|
|
172
183
|
},
|
|
173
184
|
"gpt-image/text-to-image": {
|
|
174
185
|
"models": ["gpt-image-1.5"],
|
|
175
186
|
"fields_by_model": {
|
|
176
|
-
"gpt-image-1.5": {
|
|
187
|
+
"gpt-image-1.5": {
|
|
188
|
+
"aspect_ratio": {
|
|
189
|
+
"enum": ["1:1", "2:3", "3:2"]
|
|
190
|
+
},
|
|
191
|
+
"quality": {
|
|
192
|
+
"enum": ["medium", "high"]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
177
195
|
}
|
|
178
196
|
},
|
|
179
197
|
"grok-imagine/edit-image": {
|
|
@@ -585,7 +603,7 @@ CONTRACT = {
|
|
|
585
603
|
}
|
|
586
604
|
},
|
|
587
605
|
"nano-banana/text-to-image": {
|
|
588
|
-
"models": ["nano-banana", "nano-banana-2", "nano-banana-pro"],
|
|
606
|
+
"models": ["nano-banana", "nano-banana-2", "nano-banana-2-lite", "nano-banana-pro"],
|
|
589
607
|
"fields_by_model": {
|
|
590
608
|
"nano-banana": {
|
|
591
609
|
"aspect_ratio": {
|
|
@@ -606,6 +624,11 @@ CONTRACT = {
|
|
|
606
624
|
"enum": ["1k", "2k", "4k"]
|
|
607
625
|
}
|
|
608
626
|
},
|
|
627
|
+
"nano-banana-2-lite": {
|
|
628
|
+
"aspect_ratio": {
|
|
629
|
+
"enum": ["1:1", "1:4", "1:8", "2:3", "3:2", "3:4", "4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9", "auto"]
|
|
630
|
+
}
|
|
631
|
+
},
|
|
609
632
|
"nano-banana-pro": {
|
|
610
633
|
"aspect_ratio": {
|
|
611
634
|
"enum": ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "auto"]
|
|
@@ -713,9 +736,6 @@ CONTRACT = {
|
|
|
713
736
|
"aspect_ratio": {
|
|
714
737
|
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]
|
|
715
738
|
},
|
|
716
|
-
"duration_seconds": {
|
|
717
|
-
"enum": [4, 8, 12]
|
|
718
|
-
},
|
|
719
739
|
"output_resolution": {
|
|
720
740
|
"enum": ["480p", "720p", "1080p"]
|
|
721
741
|
}
|
|
@@ -8,6 +8,8 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from email.utils import parsedate_to_datetime
|
|
9
9
|
from typing import Any, Dict, Optional
|
|
10
10
|
|
|
11
|
+
from .response import ResponseHeaders
|
|
12
|
+
|
|
11
13
|
_HTML_MARKER = re.compile(r"<!doctype|<html", re.IGNORECASE)
|
|
12
14
|
_TITLE = re.compile(r"<title>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
|
13
15
|
_H1 = re.compile(r"<h1>(.*?)</h1>", re.IGNORECASE | re.DOTALL)
|
|
@@ -29,12 +31,23 @@ class Error(Exception):
|
|
|
29
31
|
status: Optional[int] = None,
|
|
30
32
|
request_id: Optional[str] = None,
|
|
31
33
|
details: Any = None,
|
|
34
|
+
response_headers: Any = None,
|
|
32
35
|
) -> None:
|
|
33
36
|
super().__init__(message)
|
|
34
37
|
self.message = message
|
|
35
38
|
self.status = status
|
|
36
39
|
self.request_id = request_id
|
|
37
40
|
self.details = details
|
|
41
|
+
self.response_headers = (
|
|
42
|
+
response_headers if isinstance(response_headers, ResponseHeaders) else ResponseHeaders(response_headers)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def response_header(self, name: str) -> Optional[str]:
|
|
46
|
+
return self.response_headers.get(name)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def runapi_task_id(self) -> Optional[str]:
|
|
50
|
+
return self.response_header("X-RunAPI-Task-Id")
|
|
38
51
|
|
|
39
52
|
def to_dict(self) -> Dict[str, Any]:
|
|
40
53
|
data = {
|
|
@@ -184,7 +197,12 @@ def error_from_response(response: "Any") -> Error:
|
|
|
184
197
|
|
|
185
198
|
error_class = STATUS_MAP.get(status, Error)
|
|
186
199
|
|
|
187
|
-
kwargs: Dict[str, Any] = {
|
|
200
|
+
kwargs: Dict[str, Any] = {
|
|
201
|
+
"status": status,
|
|
202
|
+
"request_id": request_id,
|
|
203
|
+
"details": parsed_body,
|
|
204
|
+
"response_headers": response.headers,
|
|
205
|
+
}
|
|
188
206
|
if error_class is RateLimitError:
|
|
189
207
|
kwargs["retry_after"] = _parse_retry_after(response.headers.get("retry-after"))
|
|
190
208
|
|
|
@@ -6,13 +6,14 @@ local file or register a remote URL and receive a usable file reference.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
9
11
|
import os
|
|
10
|
-
from typing import Any, Optional
|
|
12
|
+
from typing import Any, Mapping, Optional, Union
|
|
11
13
|
|
|
12
14
|
from . import auth
|
|
13
15
|
from .http_client import HttpClient
|
|
14
16
|
from .models import BaseModel, optional, required
|
|
15
|
-
from .multipart import MultipartBody, MultipartFile
|
|
16
17
|
from .options import ClientOptions, RequestOptions
|
|
17
18
|
from .resource import Resource
|
|
18
19
|
|
|
@@ -28,6 +29,8 @@ class UploadResponse(BaseModel):
|
|
|
28
29
|
|
|
29
30
|
class FilesClient(Resource):
|
|
30
31
|
ENDPOINT = "/api/v1/files"
|
|
32
|
+
PREPARE_ENDPOINT = f"{ENDPOINT}/prepare"
|
|
33
|
+
CONFIRM_ENDPOINT = f"{ENDPOINT}/confirm"
|
|
31
34
|
|
|
32
35
|
RESPONSE_CLASS = UploadResponse
|
|
33
36
|
|
|
@@ -40,7 +43,7 @@ class FilesClient(Resource):
|
|
|
40
43
|
def create(
|
|
41
44
|
self,
|
|
42
45
|
file: Any = None,
|
|
43
|
-
source: Optional[str] = None,
|
|
46
|
+
source: Optional[Union[str, Mapping[str, Any]]] = None,
|
|
44
47
|
file_name: Optional[str] = None,
|
|
45
48
|
options: Optional[RequestOptions] = None,
|
|
46
49
|
) -> Any:
|
|
@@ -50,7 +53,8 @@ class FilesClient(Resource):
|
|
|
50
53
|
|
|
51
54
|
Args:
|
|
52
55
|
file: Local file path or file-like object to upload.
|
|
53
|
-
source: Remote URL
|
|
56
|
+
source: Remote URL, base64 data, or canonical source object to
|
|
57
|
+
register instead of uploading a local file.
|
|
54
58
|
file_name: Optional name to record for the uploaded file.
|
|
55
59
|
options: Optional per-request options.
|
|
56
60
|
|
|
@@ -60,14 +64,42 @@ class FilesClient(Resource):
|
|
|
60
64
|
self._validate_source(file, source)
|
|
61
65
|
|
|
62
66
|
if file is not None:
|
|
63
|
-
|
|
64
|
-
else:
|
|
65
|
-
body = self._compact_params({"source": source, "file_name": file_name})
|
|
67
|
+
return self._upload_direct(file, file_name, options)
|
|
66
68
|
|
|
69
|
+
body = self._compact_params(
|
|
70
|
+
{"source": self._source_object(source), "file_name": file_name}
|
|
71
|
+
)
|
|
67
72
|
return self._request("post", self.ENDPOINT, body=body, options=options)
|
|
68
73
|
|
|
74
|
+
def _upload_direct(self, file: Any, file_name: Optional[str], options: Optional[RequestOptions]) -> Any:
|
|
75
|
+
"""Local files upload straight to storage: ask for a pre-authorized target,
|
|
76
|
+
PUT the bytes there (never through the API), then confirm. The caller still
|
|
77
|
+
makes a single create call."""
|
|
78
|
+
path = self._file_path(file)
|
|
79
|
+
with open(path, "rb") as handle:
|
|
80
|
+
data = handle.read()
|
|
81
|
+
|
|
82
|
+
prepared = self._http.request(
|
|
83
|
+
"post",
|
|
84
|
+
self.PREPARE_ENDPOINT,
|
|
85
|
+
body=self._compact_params(
|
|
86
|
+
{
|
|
87
|
+
"filename": file_name or os.path.basename(path),
|
|
88
|
+
"byte_size": len(data),
|
|
89
|
+
"checksum": base64.b64encode(hashlib.md5(data).digest()).decode("ascii"),
|
|
90
|
+
}
|
|
91
|
+
),
|
|
92
|
+
options=options,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._http.upload(prepared["upload_url"], headers=prepared["headers"], body=data)
|
|
96
|
+
|
|
97
|
+
return self._request(
|
|
98
|
+
"post", self.CONFIRM_ENDPOINT, body={"signed_id": prepared["signed_id"]}, options=options
|
|
99
|
+
)
|
|
100
|
+
|
|
69
101
|
@staticmethod
|
|
70
|
-
def _validate_source(file: Any, source: Optional[str]) -> None:
|
|
102
|
+
def _validate_source(file: Any, source: Optional[Union[str, Mapping[str, Any]]]) -> None:
|
|
71
103
|
def present(value: Any) -> bool:
|
|
72
104
|
if value is None:
|
|
73
105
|
return False
|
|
@@ -79,13 +111,18 @@ class FilesClient(Resource):
|
|
|
79
111
|
if provided != 1:
|
|
80
112
|
raise ValueError("Exactly one source is required: file or source")
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _source_object(source: Optional[Union[str, Mapping[str, Any]]]) -> Any:
|
|
116
|
+
if isinstance(source, Mapping):
|
|
117
|
+
return dict(source)
|
|
118
|
+
|
|
119
|
+
if isinstance(source, str):
|
|
120
|
+
value = source.strip()
|
|
121
|
+
if value.lower().startswith(("http://", "https://")):
|
|
122
|
+
return {"type": "url", "url": value}
|
|
123
|
+
return {"type": "base64", "data": value}
|
|
124
|
+
|
|
125
|
+
return source
|
|
89
126
|
|
|
90
127
|
@staticmethod
|
|
91
128
|
def _file_path(file: Any) -> str:
|
|
@@ -13,6 +13,7 @@ from . import constants
|
|
|
13
13
|
from .errors import NetworkError, RateLimitError, TimeoutError, error_from_response
|
|
14
14
|
from .multipart import MultipartBody
|
|
15
15
|
from .options import ClientOptions, RequestOptions
|
|
16
|
+
from .response import ApiResponse
|
|
16
17
|
|
|
17
18
|
_NO_BODY = object()
|
|
18
19
|
|
|
@@ -37,6 +38,9 @@ class HttpClient:
|
|
|
37
38
|
"User-Agent": constants.SDK_USER_AGENT,
|
|
38
39
|
},
|
|
39
40
|
)
|
|
41
|
+
# A bare client for direct uploads: the pre-authorized upload URL lives
|
|
42
|
+
# outside the API host and must not receive the API key.
|
|
43
|
+
self._upload_client = httpx.Client(timeout=options.timeout, transport=transport)
|
|
40
44
|
|
|
41
45
|
def request(
|
|
42
46
|
self,
|
|
@@ -74,7 +78,12 @@ class HttpClient:
|
|
|
74
78
|
raise NetworkError(str(exc))
|
|
75
79
|
|
|
76
80
|
if response.is_success:
|
|
77
|
-
|
|
81
|
+
body = self._parse_body(response.text)
|
|
82
|
+
if body is None:
|
|
83
|
+
return None
|
|
84
|
+
if isinstance(body, (dict, list)):
|
|
85
|
+
return ApiResponse(body, response.headers)
|
|
86
|
+
return body
|
|
78
87
|
|
|
79
88
|
error = error_from_response(response)
|
|
80
89
|
if self._retryable(method, response.status_code) and retries < max_retries:
|
|
@@ -87,8 +96,23 @@ class HttpClient:
|
|
|
87
96
|
for handle in opened:
|
|
88
97
|
handle.close()
|
|
89
98
|
|
|
99
|
+
def upload(self, url: str, headers: Dict[str, str], body: bytes) -> None:
|
|
100
|
+
"""PUT bytes straight to a pre-authorized upload URL with the exact headers
|
|
101
|
+
issued for it. No auth, no retries: the URL is single-use and the body is
|
|
102
|
+
not safe to replay."""
|
|
103
|
+
try:
|
|
104
|
+
response = self._upload_client.put(url, content=body, headers=headers)
|
|
105
|
+
except httpx.TimeoutException as exc:
|
|
106
|
+
raise TimeoutError(str(exc))
|
|
107
|
+
except httpx.TransportError as exc:
|
|
108
|
+
raise NetworkError(str(exc))
|
|
109
|
+
|
|
110
|
+
if not response.is_success:
|
|
111
|
+
raise error_from_response(response)
|
|
112
|
+
|
|
90
113
|
def close(self) -> None:
|
|
91
114
|
self._client.close()
|
|
115
|
+
self._upload_client.close()
|
|
92
116
|
|
|
93
117
|
def _build_payload(
|
|
94
118
|
self, body: Any
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
|
21
21
|
|
|
22
22
|
from .errors import ValidationError
|
|
23
|
+
from .response import ResponseHeaders
|
|
23
24
|
|
|
24
25
|
# A field's declared type. ``None`` means "untyped" (coerced dynamically); a
|
|
25
26
|
# class is used directly; a callable is invoked lazily to resolve a forward
|
|
@@ -86,6 +87,7 @@ class BaseModel:
|
|
|
86
87
|
def __init__(self, attributes: Optional[Dict[str, Any]] = None) -> None:
|
|
87
88
|
source = self._normalize_input(attributes)
|
|
88
89
|
self._attributes: Dict[str, Any] = {}
|
|
90
|
+
self._response_headers = ResponseHeaders()
|
|
89
91
|
|
|
90
92
|
for field in self._fields.values():
|
|
91
93
|
if field.name in source:
|
|
@@ -115,7 +117,7 @@ class BaseModel:
|
|
|
115
117
|
return None
|
|
116
118
|
if isinstance(value, BaseModel):
|
|
117
119
|
if isinstance(target, type) and issubclass(target, BaseModel) and not isinstance(value, target):
|
|
118
|
-
return target.from_dict(value.to_dict())
|
|
120
|
+
return target.from_dict(value.to_dict())._with_response_headers(value.response_headers)
|
|
119
121
|
return value
|
|
120
122
|
if isinstance(value, dict):
|
|
121
123
|
model = target if (isinstance(target, type) and issubclass(target, BaseModel)) else DynamicModel
|
|
@@ -153,6 +155,21 @@ class BaseModel:
|
|
|
153
155
|
def to_dict(self) -> Dict[str, Any]:
|
|
154
156
|
return {key: self._serialize(value) for key, value in self._attributes.items()}
|
|
155
157
|
|
|
158
|
+
@property
|
|
159
|
+
def response_headers(self) -> ResponseHeaders:
|
|
160
|
+
return self._response_headers
|
|
161
|
+
|
|
162
|
+
def response_header(self, name: str) -> Optional[str]:
|
|
163
|
+
return self._response_headers.get(name)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def runapi_task_id(self) -> Optional[str]:
|
|
167
|
+
return self.response_header("X-RunAPI-Task-Id")
|
|
168
|
+
|
|
169
|
+
def _with_response_headers(self, headers: Any) -> "BaseModel":
|
|
170
|
+
self._response_headers = headers if isinstance(headers, ResponseHeaders) else ResponseHeaders(headers)
|
|
171
|
+
return self
|
|
172
|
+
|
|
156
173
|
def __eq__(self, other: Any) -> bool:
|
|
157
174
|
if isinstance(other, BaseModel):
|
|
158
175
|
return self.to_dict() == other.to_dict()
|
|
@@ -32,13 +32,25 @@ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptio
|
|
|
32
32
|
|
|
33
33
|
if status == "failed":
|
|
34
34
|
message = _value_for(response, "error") or "Task failed"
|
|
35
|
-
raise TaskFailedError(
|
|
35
|
+
raise TaskFailedError(
|
|
36
|
+
message,
|
|
37
|
+
details=_details_for(response),
|
|
38
|
+
response_headers=_response_headers_for(response),
|
|
39
|
+
)
|
|
36
40
|
|
|
37
41
|
if time.monotonic() >= deadline:
|
|
38
|
-
raise TaskTimeoutError(
|
|
42
|
+
raise TaskTimeoutError(
|
|
43
|
+
f"Task polling timed out after {options.max_wait}s",
|
|
44
|
+
details=_details_for(response),
|
|
45
|
+
response_headers=_response_headers_for(response),
|
|
46
|
+
)
|
|
39
47
|
|
|
40
48
|
if status not in ACTIVE_STATUSES:
|
|
41
|
-
raise TaskFailedError(
|
|
49
|
+
raise TaskFailedError(
|
|
50
|
+
f"Unknown task status: {status}",
|
|
51
|
+
details=_details_for(response),
|
|
52
|
+
response_headers=_response_headers_for(response),
|
|
53
|
+
)
|
|
42
54
|
|
|
43
55
|
time.sleep(options.poll_interval)
|
|
44
56
|
|
|
@@ -53,3 +65,7 @@ def _value_for(response: Any, key: str) -> Any:
|
|
|
53
65
|
|
|
54
66
|
def _details_for(response: Any) -> Any:
|
|
55
67
|
return response.to_dict() if isinstance(response, BaseModel) else response
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _response_headers_for(response: Any) -> Any:
|
|
71
|
+
return response.response_headers if hasattr(response, "response_headers") else None
|