runapi-core 0.1.0__tar.gz → 0.1.1__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.1}/PKG-INFO +32 -1
- runapi_core-0.1.1/README.md +64 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/pyproject.toml +3 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/contract_gen.py +22 -7
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/files.py +52 -15
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/http_client.py +18 -0
- runapi_core-0.1.1/src/runapi/core/resource.py +199 -0
- runapi_core-0.1.1/src/runapi/core/version.py +1 -0
- runapi_core-0.1.1/tests/test_files.py +170 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_resource.py +45 -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 → runapi_core-0.1.1}/src/runapi/core/__init__.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/auth.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/config.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/errors.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/models.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/options.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/polling.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_auth.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_config.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_errors.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_http_client.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_models.py +0 -0
- {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_polling.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.1
|
|
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
|
|
@@ -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 model SDK methods currently return parsed response bodies. When an integration needs to send a client request id or read `X-RunAPI-Task-Id`, make the call through direct HTTP or a custom transport so response headers stay available.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import os
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
response = httpx.post(
|
|
46
|
+
"https://runapi.ai/api/v1/suno/text_to_music",
|
|
47
|
+
headers={
|
|
48
|
+
"Authorization": f"Bearer {os.environ['RUNAPI_API_KEY']}",
|
|
49
|
+
"X-Client-Request-Id": "order-123",
|
|
50
|
+
},
|
|
51
|
+
json={
|
|
52
|
+
"prompt": "A chill lo-fi beat",
|
|
53
|
+
"model": "suno-v4.5-plus",
|
|
54
|
+
"vocal_mode": "instrumental",
|
|
55
|
+
},
|
|
56
|
+
timeout=900,
|
|
57
|
+
)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
runapi_task_id = response.headers.get("X-RunAPI-Task-Id")
|
|
60
|
+
body = response.json()
|
|
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 model SDKs 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 model SDK methods currently return parsed response bodies. When an integration needs to send a client request id or read `X-RunAPI-Task-Id`, make the call through direct HTTP or a custom transport so response headers stay available.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import os
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
response = httpx.post(
|
|
33
|
+
"https://runapi.ai/api/v1/suno/text_to_music",
|
|
34
|
+
headers={
|
|
35
|
+
"Authorization": f"Bearer {os.environ['RUNAPI_API_KEY']}",
|
|
36
|
+
"X-Client-Request-Id": "order-123",
|
|
37
|
+
},
|
|
38
|
+
json={
|
|
39
|
+
"prompt": "A chill lo-fi beat",
|
|
40
|
+
"model": "suno-v4.5-plus",
|
|
41
|
+
"vocal_mode": "instrumental",
|
|
42
|
+
},
|
|
43
|
+
timeout=900,
|
|
44
|
+
)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
runapi_task_id = response.headers.get("X-RunAPI-Task-Id")
|
|
47
|
+
body = response.json()
|
|
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.
|
|
@@ -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": {
|
|
@@ -713,9 +731,6 @@ CONTRACT = {
|
|
|
713
731
|
"aspect_ratio": {
|
|
714
732
|
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9"]
|
|
715
733
|
},
|
|
716
|
-
"duration_seconds": {
|
|
717
|
-
"enum": [4, 8, 12]
|
|
718
|
-
},
|
|
719
734
|
"output_resolution": {
|
|
720
735
|
"enum": ["480p", "720p", "1080p"]
|
|
721
736
|
}
|
|
@@ -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:
|
|
@@ -37,6 +37,9 @@ class HttpClient:
|
|
|
37
37
|
"User-Agent": constants.SDK_USER_AGENT,
|
|
38
38
|
},
|
|
39
39
|
)
|
|
40
|
+
# A bare client for direct uploads: the pre-authorized upload URL lives
|
|
41
|
+
# outside the API host and must not receive the API key.
|
|
42
|
+
self._upload_client = httpx.Client(timeout=options.timeout, transport=transport)
|
|
40
43
|
|
|
41
44
|
def request(
|
|
42
45
|
self,
|
|
@@ -87,8 +90,23 @@ class HttpClient:
|
|
|
87
90
|
for handle in opened:
|
|
88
91
|
handle.close()
|
|
89
92
|
|
|
93
|
+
def upload(self, url: str, headers: Dict[str, str], body: bytes) -> None:
|
|
94
|
+
"""PUT bytes straight to a pre-authorized upload URL with the exact headers
|
|
95
|
+
issued for it. No auth, no retries: the URL is single-use and the body is
|
|
96
|
+
not safe to replay."""
|
|
97
|
+
try:
|
|
98
|
+
response = self._upload_client.put(url, content=body, headers=headers)
|
|
99
|
+
except httpx.TimeoutException as exc:
|
|
100
|
+
raise TimeoutError(str(exc))
|
|
101
|
+
except httpx.TransportError as exc:
|
|
102
|
+
raise NetworkError(str(exc))
|
|
103
|
+
|
|
104
|
+
if not response.is_success:
|
|
105
|
+
raise error_from_response(response)
|
|
106
|
+
|
|
90
107
|
def close(self) -> None:
|
|
91
108
|
self._client.close()
|
|
109
|
+
self._upload_client.close()
|
|
92
110
|
|
|
93
111
|
def _build_payload(
|
|
94
112
|
self, body: Any
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Base class for API resources: request coercion, param helpers, polling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from . import polling
|
|
8
|
+
from .errors import ValidationError
|
|
9
|
+
from .models import BaseModel, TaskResponse
|
|
10
|
+
from .options import PollingOptions, RequestOptions
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Resource:
|
|
14
|
+
"""Shared behavior for resource classes.
|
|
15
|
+
|
|
16
|
+
Subclasses set ``RESPONSE_CLASS`` (the typed model for responses) and
|
|
17
|
+
optionally ``COMPLETED_RESPONSE_CLASS`` (a narrowed model that ``run()``
|
|
18
|
+
re-coerces to once the task completes).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
RESPONSE_CLASS: type = TaskResponse
|
|
22
|
+
COMPLETED_RESPONSE_CLASS: Optional[type] = None
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: Any) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
|
|
27
|
+
def _request(
|
|
28
|
+
self,
|
|
29
|
+
method: str,
|
|
30
|
+
path: str,
|
|
31
|
+
body: Any = None,
|
|
32
|
+
options: Optional[RequestOptions] = None,
|
|
33
|
+
response_class: Optional[type] = None,
|
|
34
|
+
) -> Any:
|
|
35
|
+
response = self._http.request(method, path, body=body, options=options)
|
|
36
|
+
return BaseModel.coerce(response, as_=response_class or type(self).RESPONSE_CLASS)
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _compact_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
key: value
|
|
42
|
+
for key, value in params.items()
|
|
43
|
+
if not (value is None or (isinstance(value, str) and value.strip() == ""))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _validate_optional(params: Dict[str, Any], key: str, allowed: Sequence[Any]) -> None:
|
|
48
|
+
value = params.get(key)
|
|
49
|
+
if value is None:
|
|
50
|
+
return
|
|
51
|
+
if value not in allowed:
|
|
52
|
+
joined = ", ".join(str(option) for option in allowed)
|
|
53
|
+
raise ValidationError(f"Invalid {key}: {value}. Must be one of: {joined}")
|
|
54
|
+
|
|
55
|
+
# ---- Contract validation -------------------------------------------
|
|
56
|
+
# Validates request params against the generated contract: model
|
|
57
|
+
# membership, then per-field required/enum/integer/min/max/length, then
|
|
58
|
+
# declared cross-field rules. `schema` is one action entry from the generated
|
|
59
|
+
# per-package CONTRACT (CONTRACT["<action>"]).
|
|
60
|
+
|
|
61
|
+
def _validate_contract(self, schema: Dict[str, Any], params: Dict[str, Any]) -> None:
|
|
62
|
+
model = params.get("model")
|
|
63
|
+
models = schema.get("models", [])
|
|
64
|
+
if model not in models:
|
|
65
|
+
raise ValidationError(f"model must be one of: {', '.join(sorted(models))}")
|
|
66
|
+
|
|
67
|
+
fields = schema.get("fields_by_model", {}).get(model, {})
|
|
68
|
+
for field, rules in fields.items():
|
|
69
|
+
self._validate_schema_field(params, field, rules)
|
|
70
|
+
|
|
71
|
+
for rule in schema.get("rules", []):
|
|
72
|
+
self._enforce_rule(params, rule)
|
|
73
|
+
|
|
74
|
+
def _validate_schema_field(self, params: Dict[str, Any], field: str, rules: Dict[str, Any]) -> None:
|
|
75
|
+
present = self._field_present(params, field)
|
|
76
|
+
if rules.get("required") and not present:
|
|
77
|
+
raise ValidationError(f"{field} is required")
|
|
78
|
+
if not present:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
value = params[field]
|
|
82
|
+
enum = rules.get("enum")
|
|
83
|
+
if enum is not None and not self._enum_allowed(enum, value):
|
|
84
|
+
raise ValidationError(f"{field} must be one of: {', '.join(str(option) for option in enum)}")
|
|
85
|
+
|
|
86
|
+
if rules.get("type") == "integer":
|
|
87
|
+
self._validate_integer(field, value, rules)
|
|
88
|
+
|
|
89
|
+
if "min" in rules or "max" in rules:
|
|
90
|
+
self._validate_range(field, value, rules)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _validate_integer(field: str, value: Any, rules: Dict[str, Any]) -> None:
|
|
94
|
+
# Mirrors GatewayEntry#validate_schema_integer!: a type: integer field
|
|
95
|
+
# rejects non-integer numbers (e.g. 11.5), which min/max alone admit.
|
|
96
|
+
# bool is an int subclass in Python, so exclude it explicitly.
|
|
97
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
98
|
+
return
|
|
99
|
+
minimum = rules.get("min")
|
|
100
|
+
maximum = rules.get("max")
|
|
101
|
+
detail = (
|
|
102
|
+
f" between {minimum} and {maximum}"
|
|
103
|
+
if minimum is not None and maximum is not None
|
|
104
|
+
else ""
|
|
105
|
+
)
|
|
106
|
+
raise ValidationError(f"{field} must be an integer{detail}")
|
|
107
|
+
|
|
108
|
+
def _validate_range(self, field: str, value: Any, rules: Dict[str, Any]) -> None:
|
|
109
|
+
if rules.get("length"):
|
|
110
|
+
measured: Any = len(str(value))
|
|
111
|
+
unit: Optional[str] = "characters"
|
|
112
|
+
else:
|
|
113
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
114
|
+
raise ValidationError(f"{field} must be a number")
|
|
115
|
+
measured = value
|
|
116
|
+
unit = None
|
|
117
|
+
|
|
118
|
+
minimum = rules.get("min")
|
|
119
|
+
maximum = rules.get("max")
|
|
120
|
+
if (minimum is None or measured >= minimum) and (maximum is None or measured <= maximum):
|
|
121
|
+
return
|
|
122
|
+
raise ValidationError(self._range_message(field, minimum, maximum, unit))
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _range_message(field: str, minimum: Any, maximum: Any, unit: Optional[str]) -> str:
|
|
126
|
+
suffix = f" {unit}" if unit else ""
|
|
127
|
+
if minimum is not None and maximum is not None:
|
|
128
|
+
return f"{field} must be between {minimum} and {maximum}{suffix}"
|
|
129
|
+
if minimum is not None:
|
|
130
|
+
return f"{field} must be at least {minimum}{suffix}"
|
|
131
|
+
return f"{field} must be at most {maximum}{suffix}"
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _enum_allowed(enum: Sequence[Any], value: Any) -> bool:
|
|
135
|
+
value_is_num = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
136
|
+
for allowed in enum:
|
|
137
|
+
allowed_is_num = isinstance(allowed, (int, float)) and not isinstance(allowed, bool)
|
|
138
|
+
if allowed_is_num:
|
|
139
|
+
if value_is_num and value == allowed:
|
|
140
|
+
return True
|
|
141
|
+
elif value_is_num:
|
|
142
|
+
if allowed == value:
|
|
143
|
+
return True
|
|
144
|
+
elif str(allowed) == str(value):
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def _enforce_rule(self, params: Dict[str, Any], rule: Dict[str, Any]) -> None:
|
|
149
|
+
conditions = rule.get("when", {})
|
|
150
|
+
if not all(self._rule_condition_met(params, key, val) for key, val in conditions.items()):
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
context = " and ".join(f"{key} is {val}" for key, val in conditions.items())
|
|
154
|
+
for field in rule.get("required", []):
|
|
155
|
+
if not self._field_present(params, field):
|
|
156
|
+
raise ValidationError(f"{field} is required when {context}")
|
|
157
|
+
for field in rule.get("forbidden", []):
|
|
158
|
+
if self._field_present(params, field):
|
|
159
|
+
raise ValidationError(f"{field} is not allowed when {context}")
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _rule_condition_met(params: Dict[str, Any], field: str, value: Any) -> bool:
|
|
163
|
+
if field not in params:
|
|
164
|
+
return False
|
|
165
|
+
return str(params[field]) == str(value)
|
|
166
|
+
|
|
167
|
+
def _field_present(self, params: Dict[str, Any], field: str) -> bool:
|
|
168
|
+
if field not in params:
|
|
169
|
+
return False
|
|
170
|
+
value = params[field]
|
|
171
|
+
if value is False:
|
|
172
|
+
return True
|
|
173
|
+
if isinstance(value, (list, tuple)):
|
|
174
|
+
return any(self._present(item) for item in value)
|
|
175
|
+
return self._present(value)
|
|
176
|
+
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _present(value: Any) -> bool:
|
|
179
|
+
if value is None or value is False:
|
|
180
|
+
return False
|
|
181
|
+
if value is True:
|
|
182
|
+
return True
|
|
183
|
+
if isinstance(value, str):
|
|
184
|
+
return value.strip() != ""
|
|
185
|
+
if isinstance(value, (list, tuple, dict)):
|
|
186
|
+
return len(value) > 0
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def _poll_until_complete(
|
|
190
|
+
self, fetch: Callable[[], Any], polling_opts: Optional[PollingOptions] = None
|
|
191
|
+
) -> Any:
|
|
192
|
+
response = polling.poll_until_complete(fetch, polling_opts or PollingOptions())
|
|
193
|
+
|
|
194
|
+
completed_class = type(self).COMPLETED_RESPONSE_CLASS
|
|
195
|
+
if completed_class is None or isinstance(response, completed_class):
|
|
196
|
+
return response
|
|
197
|
+
|
|
198
|
+
payload = response.to_dict() if isinstance(response, BaseModel) else response
|
|
199
|
+
return completed_class.from_dict(payload)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from runapi.core import FilesClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PREPARED = {
|
|
12
|
+
"signed_id": "signed-blob-id",
|
|
13
|
+
"upload_url": "https://file.runapi.ai/temp/user-uploads/key",
|
|
14
|
+
"headers": {"Content-Type": "application/octet-stream"},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FakeHttp:
|
|
19
|
+
def __init__(self, response):
|
|
20
|
+
self._response = response
|
|
21
|
+
self.calls = []
|
|
22
|
+
self.uploads = []
|
|
23
|
+
|
|
24
|
+
def request(self, method, path, body=None, options=None):
|
|
25
|
+
self.calls.append((method, path, body))
|
|
26
|
+
if path.endswith("/prepare"):
|
|
27
|
+
return PREPARED
|
|
28
|
+
return self._response
|
|
29
|
+
|
|
30
|
+
def upload(self, url, headers, body):
|
|
31
|
+
self.uploads.append((url, headers, body))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
UPLOAD = {
|
|
35
|
+
"file_name": "image.png",
|
|
36
|
+
"url": "https://cdn.runapi.ai/x.png",
|
|
37
|
+
"size_bytes": 3,
|
|
38
|
+
"mime_type": "image/png",
|
|
39
|
+
"created_at": "2026-01-01T00:00:00Z",
|
|
40
|
+
"expires_at": "2026-01-08T00:00:00Z",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_requires_exactly_one_source():
|
|
45
|
+
fake = FakeHttp(UPLOAD)
|
|
46
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
47
|
+
with pytest.raises(ValueError):
|
|
48
|
+
client.create()
|
|
49
|
+
with pytest.raises(ValueError):
|
|
50
|
+
client.create(file="x", source="y")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_create_with_remote_source_sends_url_source_object():
|
|
54
|
+
fake = FakeHttp(UPLOAD)
|
|
55
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
56
|
+
result = client.create(source="https://runapi.ai/in.png")
|
|
57
|
+
|
|
58
|
+
method, path, body = fake.calls[0]
|
|
59
|
+
assert method == "post"
|
|
60
|
+
assert path == "/api/v1/files"
|
|
61
|
+
assert body == {"source": {"type": "url", "url": "https://runapi.ai/in.png"}}
|
|
62
|
+
assert result.url == UPLOAD["url"]
|
|
63
|
+
assert result.size_bytes == 3
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_create_with_uppercase_url_scheme_sends_url_source_object():
|
|
67
|
+
fake = FakeHttp(UPLOAD)
|
|
68
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
69
|
+
|
|
70
|
+
client.create(source="HTTPS://cdn.runapi.ai/public/samples/in.png")
|
|
71
|
+
|
|
72
|
+
_, _, body = fake.calls[0]
|
|
73
|
+
assert body == {"source": {"type": "url", "url": "HTTPS://cdn.runapi.ai/public/samples/in.png"}}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_create_with_base64_source_sends_base64_source_object():
|
|
77
|
+
fake = FakeHttp(UPLOAD)
|
|
78
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
79
|
+
source = "cG5n"
|
|
80
|
+
|
|
81
|
+
client.create(source=source, file_name="image.png")
|
|
82
|
+
|
|
83
|
+
_, _, body = fake.calls[0]
|
|
84
|
+
assert body == {
|
|
85
|
+
"source": {"type": "base64", "data": source},
|
|
86
|
+
"file_name": "image.png",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_create_with_source_object_sends_canonical_source_object():
|
|
91
|
+
fake = FakeHttp(UPLOAD)
|
|
92
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
93
|
+
|
|
94
|
+
client.create(source={"type": "url", "url": "https://cdn.runapi.ai/public/samples/in.png"})
|
|
95
|
+
|
|
96
|
+
_, _, body = fake.calls[0]
|
|
97
|
+
assert body == {"source": {"type": "url", "url": "https://cdn.runapi.ai/public/samples/in.png"}}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_create_with_local_file_uploads_directly():
|
|
101
|
+
fake = FakeHttp(UPLOAD)
|
|
102
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
103
|
+
|
|
104
|
+
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as handle:
|
|
105
|
+
handle.write(b"png")
|
|
106
|
+
path = handle.name
|
|
107
|
+
try:
|
|
108
|
+
result = client.create(file=path)
|
|
109
|
+
finally:
|
|
110
|
+
os.unlink(path)
|
|
111
|
+
|
|
112
|
+
# prepare then confirm; bytes never travel through the API
|
|
113
|
+
assert [call[1] for call in fake.calls] == ["/api/v1/files/prepare", "/api/v1/files/confirm"]
|
|
114
|
+
_, _, prepare_body = fake.calls[0]
|
|
115
|
+
assert prepare_body["filename"] == os.path.basename(path)
|
|
116
|
+
assert prepare_body["byte_size"] == 3
|
|
117
|
+
assert prepare_body["checksum"] == base64.b64encode(hashlib.md5(b"png").digest()).decode("ascii")
|
|
118
|
+
|
|
119
|
+
# bytes go straight to the issued upload URL with its headers
|
|
120
|
+
assert fake.uploads == [(PREPARED["upload_url"], PREPARED["headers"], b"png")]
|
|
121
|
+
|
|
122
|
+
_, _, confirm_body = fake.calls[1]
|
|
123
|
+
assert confirm_body == {"signed_id": "signed-blob-id"}
|
|
124
|
+
assert result.url == UPLOAD["url"]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_create_uses_explicit_file_name():
|
|
128
|
+
fake = FakeHttp(UPLOAD)
|
|
129
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
130
|
+
|
|
131
|
+
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as handle:
|
|
132
|
+
handle.write(b"png")
|
|
133
|
+
path = handle.name
|
|
134
|
+
try:
|
|
135
|
+
client.create(file=path, file_name="custom.png")
|
|
136
|
+
finally:
|
|
137
|
+
os.unlink(path)
|
|
138
|
+
|
|
139
|
+
_, _, prepare_body = fake.calls[0]
|
|
140
|
+
assert prepare_body["filename"] == "custom.png"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_create_with_pathlib_path_keeps_full_path():
|
|
144
|
+
# Regression: a pathlib.Path must keep its full path, not collapse to the
|
|
145
|
+
# basename (Path.name), so the later open() reads the right file.
|
|
146
|
+
import pathlib
|
|
147
|
+
|
|
148
|
+
fake = FakeHttp(UPLOAD)
|
|
149
|
+
client = FilesClient(api_key="k", http_client=fake)
|
|
150
|
+
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as handle:
|
|
151
|
+
handle.write(b"png")
|
|
152
|
+
path = handle.name
|
|
153
|
+
try:
|
|
154
|
+
client.create(file=pathlib.Path(path))
|
|
155
|
+
finally:
|
|
156
|
+
os.unlink(path)
|
|
157
|
+
|
|
158
|
+
# the full path was read (checksum of the real bytes), not a basename miss
|
|
159
|
+
_, _, prepare_body = fake.calls[0]
|
|
160
|
+
assert prepare_body["checksum"] == base64.b64encode(hashlib.md5(b"png").digest()).decode("ascii")
|
|
161
|
+
assert fake.uploads[0][2] == b"png"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_create_rejects_blank_source():
|
|
165
|
+
# Regression: a blank source must fail locally, not POST an empty body.
|
|
166
|
+
client = FilesClient(api_key="k", http_client=FakeHttp(UPLOAD))
|
|
167
|
+
with pytest.raises(ValueError):
|
|
168
|
+
client.create(source="")
|
|
169
|
+
with pytest.raises(ValueError):
|
|
170
|
+
client.create(source=" ")
|
|
@@ -47,6 +47,51 @@ def test_validate_optional_passes_and_rejects():
|
|
|
47
47
|
Resource._validate_optional({"k": "z"}, "k", ["a", "b"])
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
INT_SCHEMA = {
|
|
51
|
+
"models": ["m"],
|
|
52
|
+
"fields_by_model": {
|
|
53
|
+
"m": {
|
|
54
|
+
"duration_int": {"type": "integer", "min": 4, "max": 12},
|
|
55
|
+
"tolerance": {"type": "integer"},
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_validate(params):
|
|
62
|
+
resource = SampleResource(FakeHttp())
|
|
63
|
+
try:
|
|
64
|
+
resource._validate_contract(INT_SCHEMA, params)
|
|
65
|
+
return ""
|
|
66
|
+
except ValidationError as err:
|
|
67
|
+
return str(err)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_validate_integer_rejects_non_integer_within_range():
|
|
71
|
+
assert _run_validate({"model": "m", "duration_int": 11.5}) == "duration_int must be an integer between 4 and 12"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_validate_integer_runs_before_range():
|
|
75
|
+
# A non-integer below the range reports the integer error, not the range one.
|
|
76
|
+
assert _run_validate({"model": "m", "duration_int": 2.5}) == "duration_int must be an integer between 4 and 12"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_validate_bare_integer_has_no_range_detail():
|
|
80
|
+
assert _run_validate({"model": "m", "tolerance": 3.5}) == "tolerance must be an integer"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_validate_integer_still_enforces_range_for_valid_int():
|
|
84
|
+
assert _run_validate({"model": "m", "duration_int": 13}) == "duration_int must be between 4 and 12"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_validate_integer_rejects_bool_and_whole_float():
|
|
88
|
+
# bool is an int subclass but is not a valid integer value.
|
|
89
|
+
assert _run_validate({"model": "m", "tolerance": True}) == "tolerance must be an integer"
|
|
90
|
+
# Python keeps the float/int distinction: a whole-valued float is still a float.
|
|
91
|
+
assert _run_validate({"model": "m", "tolerance": 5.0}) == "tolerance must be an integer"
|
|
92
|
+
assert _run_validate({"model": "m", "tolerance": 5}) == ""
|
|
93
|
+
|
|
94
|
+
|
|
50
95
|
def test_poll_recoerces_to_completed_class():
|
|
51
96
|
resource = SampleResource(FakeHttp())
|
|
52
97
|
response = TaskResponse({"id": "1", "status": "completed", "images": [{"url": "u"}]})
|
runapi_core-0.1.0/README.md
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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 model SDKs 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
|
-
```python
|
|
22
|
-
from runapi.core import FilesClient
|
|
23
|
-
|
|
24
|
-
files = FilesClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
|
|
25
|
-
uploaded = files.create(file="./input.png")
|
|
26
|
-
print(uploaded.url)
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
|
|
30
|
-
|
|
31
|
-
## License
|
|
32
|
-
|
|
33
|
-
Licensed under the Apache License, Version 2.0.
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
"""Base class for API resources: request coercion, param helpers, polling."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any, Callable, Dict, Optional, Sequence
|
|
6
|
-
|
|
7
|
-
from . import polling
|
|
8
|
-
from .errors import ValidationError
|
|
9
|
-
from .models import BaseModel, TaskResponse
|
|
10
|
-
from .options import PollingOptions, RequestOptions
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class Resource:
|
|
14
|
-
"""Shared behavior for resource classes.
|
|
15
|
-
|
|
16
|
-
Subclasses set ``RESPONSE_CLASS`` (the typed model for responses) and
|
|
17
|
-
optionally ``COMPLETED_RESPONSE_CLASS`` (a narrowed model that ``run()``
|
|
18
|
-
re-coerces to once the task completes).
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
RESPONSE_CLASS: type = TaskResponse
|
|
22
|
-
COMPLETED_RESPONSE_CLASS: Optional[type] = None
|
|
23
|
-
|
|
24
|
-
def __init__(self, http: Any) -> None:
|
|
25
|
-
self._http = http
|
|
26
|
-
|
|
27
|
-
def _request(
|
|
28
|
-
self,
|
|
29
|
-
method: str,
|
|
30
|
-
path: str,
|
|
31
|
-
body: Any = None,
|
|
32
|
-
options: Optional[RequestOptions] = None,
|
|
33
|
-
response_class: Optional[type] = None,
|
|
34
|
-
) -> Any:
|
|
35
|
-
response = self._http.request(method, path, body=body, options=options)
|
|
36
|
-
return BaseModel.coerce(response, as_=response_class or type(self).RESPONSE_CLASS)
|
|
37
|
-
|
|
38
|
-
@staticmethod
|
|
39
|
-
def _compact_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
40
|
-
return {
|
|
41
|
-
key: value
|
|
42
|
-
for key, value in params.items()
|
|
43
|
-
if not (value is None or (isinstance(value, str) and value.strip() == ""))
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
@staticmethod
|
|
47
|
-
def _validate_optional(params: Dict[str, Any], key: str, allowed: Sequence[Any]) -> None:
|
|
48
|
-
value = params.get(key)
|
|
49
|
-
if value is None:
|
|
50
|
-
return
|
|
51
|
-
if value not in allowed:
|
|
52
|
-
joined = ", ".join(str(option) for option in allowed)
|
|
53
|
-
raise ValidationError(f"Invalid {key}: {value}. Must be one of: {joined}")
|
|
54
|
-
|
|
55
|
-
def _poll_until_complete(
|
|
56
|
-
self, fetch: Callable[[], Any], polling_opts: Optional[PollingOptions] = None
|
|
57
|
-
) -> Any:
|
|
58
|
-
response = polling.poll_until_complete(fetch, polling_opts or PollingOptions())
|
|
59
|
-
|
|
60
|
-
completed_class = type(self).COMPLETED_RESPONSE_CLASS
|
|
61
|
-
if completed_class is None or isinstance(response, completed_class):
|
|
62
|
-
return response
|
|
63
|
-
|
|
64
|
-
payload = response.to_dict() if isinstance(response, BaseModel) else response
|
|
65
|
-
return completed_class.from_dict(payload)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import tempfile
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from runapi.core import FilesClient
|
|
7
|
-
from runapi.core.multipart import MultipartBody
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class FakeHttp:
|
|
11
|
-
def __init__(self, response):
|
|
12
|
-
self._response = response
|
|
13
|
-
self.calls = []
|
|
14
|
-
|
|
15
|
-
def request(self, method, path, body=None, options=None):
|
|
16
|
-
self.calls.append((method, path, body))
|
|
17
|
-
return self._response
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
UPLOAD = {
|
|
21
|
-
"file_name": "image.png",
|
|
22
|
-
"url": "https://cdn.runapi.ai/x.png",
|
|
23
|
-
"size_bytes": 3,
|
|
24
|
-
"mime_type": "image/png",
|
|
25
|
-
"created_at": "2026-01-01T00:00:00Z",
|
|
26
|
-
"expires_at": "2026-01-08T00:00:00Z",
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def test_requires_exactly_one_source():
|
|
31
|
-
fake = FakeHttp(UPLOAD)
|
|
32
|
-
client = FilesClient(api_key="k", http_client=fake)
|
|
33
|
-
with pytest.raises(ValueError):
|
|
34
|
-
client.create()
|
|
35
|
-
with pytest.raises(ValueError):
|
|
36
|
-
client.create(file="x", source="y")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_create_with_remote_source_sends_compact_params():
|
|
40
|
-
fake = FakeHttp(UPLOAD)
|
|
41
|
-
client = FilesClient(api_key="k", http_client=fake)
|
|
42
|
-
result = client.create(source="https://example.com/in.png")
|
|
43
|
-
|
|
44
|
-
method, path, body = fake.calls[0]
|
|
45
|
-
assert method == "post"
|
|
46
|
-
assert path == "/api/v1/files"
|
|
47
|
-
assert body == {"source": "https://example.com/in.png"}
|
|
48
|
-
assert result.url == UPLOAD["url"]
|
|
49
|
-
assert result.size_bytes == 3
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_create_with_local_file_builds_multipart():
|
|
53
|
-
fake = FakeHttp(UPLOAD)
|
|
54
|
-
client = FilesClient(api_key="k", http_client=fake)
|
|
55
|
-
|
|
56
|
-
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as handle:
|
|
57
|
-
handle.write(b"png")
|
|
58
|
-
path = handle.name
|
|
59
|
-
try:
|
|
60
|
-
client.create(file=path)
|
|
61
|
-
finally:
|
|
62
|
-
os.unlink(path)
|
|
63
|
-
|
|
64
|
-
_, _, body = fake.calls[0]
|
|
65
|
-
assert isinstance(body, MultipartBody)
|
|
66
|
-
assert "file" in body.files
|
|
67
|
-
assert body.files["file"].filename == os.path.basename(path)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def test_create_uses_explicit_file_name():
|
|
71
|
-
fake = FakeHttp(UPLOAD)
|
|
72
|
-
client = FilesClient(api_key="k", http_client=fake)
|
|
73
|
-
|
|
74
|
-
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as handle:
|
|
75
|
-
handle.write(b"png")
|
|
76
|
-
path = handle.name
|
|
77
|
-
try:
|
|
78
|
-
client.create(file=path, file_name="custom.png")
|
|
79
|
-
finally:
|
|
80
|
-
os.unlink(path)
|
|
81
|
-
|
|
82
|
-
_, _, body = fake.calls[0]
|
|
83
|
-
assert body.files["file"].filename == "custom.png"
|
|
84
|
-
assert body.fields == {"file_name": "custom.png"}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def test_create_with_pathlib_path_keeps_full_path():
|
|
88
|
-
# Regression: a pathlib.Path must keep its full path, not collapse to the
|
|
89
|
-
# basename (Path.name), so the later open() reads the right file.
|
|
90
|
-
import pathlib
|
|
91
|
-
|
|
92
|
-
fake = FakeHttp(UPLOAD)
|
|
93
|
-
client = FilesClient(api_key="k", http_client=fake)
|
|
94
|
-
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as handle:
|
|
95
|
-
handle.write(b"png")
|
|
96
|
-
path = handle.name
|
|
97
|
-
try:
|
|
98
|
-
client.create(file=pathlib.Path(path))
|
|
99
|
-
finally:
|
|
100
|
-
os.unlink(path)
|
|
101
|
-
|
|
102
|
-
_, _, body = fake.calls[0]
|
|
103
|
-
assert body.files["file"].path == path
|
|
104
|
-
assert body.files["file"].filename == os.path.basename(path)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def test_create_rejects_blank_source():
|
|
108
|
-
# Regression: a blank source must fail locally, not POST an empty body.
|
|
109
|
-
client = FilesClient(api_key="k", http_client=FakeHttp(UPLOAD))
|
|
110
|
-
with pytest.raises(ValueError):
|
|
111
|
-
client.create(source="")
|
|
112
|
-
with pytest.raises(ValueError):
|
|
113
|
-
client.create(source=" ")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|