runapi-core 0.1.1__tar.gz → 0.1.3__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.1 → runapi_core-0.1.3}/PKG-INFO +20 -20
- {runapi_core-0.1.1 → runapi_core-0.1.3}/README.md +19 -19
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/__init__.py +3 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/contract_gen.py +22 -8
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/errors.py +19 -1
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/http_client.py +7 -1
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/models.py +18 -1
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/polling.py +19 -3
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/resource.py +23 -5
- runapi_core-0.1.3/src/runapi/core/response.py +56 -0
- runapi_core-0.1.3/src/runapi/core/version.py +1 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_http_client.py +50 -1
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_models.py +7 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_polling.py +24 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_resource.py +44 -1
- runapi_core-0.1.1/src/runapi/core/version.py +0 -1
- {runapi_core-0.1.1 → runapi_core-0.1.3}/pyproject.toml +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/auth.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/config.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/constants.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/files.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/multipart.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/options.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/src/runapi/core/py.typed +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_auth.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_config.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_errors.py +0 -0
- {runapi_core-0.1.1 → runapi_core-0.1.3}/tests/test_files.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.3
|
|
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
|
|
@@ -35,29 +35,29 @@ runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
|
|
|
35
35
|
|
|
36
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
37
|
|
|
38
|
-
High-level Python
|
|
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
39
|
|
|
40
40
|
```python
|
|
41
41
|
import os
|
|
42
42
|
|
|
43
|
-
import
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
timeout=900,
|
|
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,
|
|
57
56
|
)
|
|
58
|
-
|
|
59
|
-
runapi_task_id = response.
|
|
60
|
-
|
|
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
61
|
```
|
|
62
62
|
|
|
63
63
|
```python
|
|
@@ -10,7 +10,7 @@ pip install runapi-core
|
|
|
10
10
|
|
|
11
11
|
## Notes
|
|
12
12
|
|
|
13
|
-
Use the core package for common client options, error classes, request helpers, file uploads, and task polling behavior that
|
|
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
14
|
|
|
15
15
|
```python
|
|
16
16
|
import runapi.core as runapi
|
|
@@ -22,29 +22,29 @@ runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
|
|
|
22
22
|
|
|
23
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
24
|
|
|
25
|
-
High-level Python
|
|
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
26
|
|
|
27
27
|
```python
|
|
28
28
|
import os
|
|
29
29
|
|
|
30
|
-
import
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
timeout=900,
|
|
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,
|
|
44
43
|
)
|
|
45
|
-
|
|
46
|
-
runapi_task_id = response.
|
|
47
|
-
|
|
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
48
|
```
|
|
49
49
|
|
|
50
50
|
```python
|
|
@@ -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",
|
|
@@ -478,10 +478,7 @@ CONTRACT = {
|
|
|
478
478
|
},
|
|
479
479
|
"imagen-4-fast": {
|
|
480
480
|
"aspect_ratio": {
|
|
481
|
-
"enum": ["1:1", "16:9", "9:16", "3:4", "4:3"]
|
|
482
|
-
},
|
|
483
|
-
"output_count": {
|
|
484
|
-
"enum": [1, 2, 3, 4]
|
|
481
|
+
"enum": ["1:1", "16:9", "9:16", "3:4", "4:3", "auto"]
|
|
485
482
|
}
|
|
486
483
|
},
|
|
487
484
|
"imagen-4-ultra": {
|
|
@@ -603,7 +600,7 @@ CONTRACT = {
|
|
|
603
600
|
}
|
|
604
601
|
},
|
|
605
602
|
"nano-banana/text-to-image": {
|
|
606
|
-
"models": ["nano-banana", "nano-banana-2", "nano-banana-pro"],
|
|
603
|
+
"models": ["nano-banana", "nano-banana-2", "nano-banana-2-lite", "nano-banana-pro"],
|
|
607
604
|
"fields_by_model": {
|
|
608
605
|
"nano-banana": {
|
|
609
606
|
"aspect_ratio": {
|
|
@@ -624,6 +621,11 @@ CONTRACT = {
|
|
|
624
621
|
"enum": ["1k", "2k", "4k"]
|
|
625
622
|
}
|
|
626
623
|
},
|
|
624
|
+
"nano-banana-2-lite": {
|
|
625
|
+
"aspect_ratio": {
|
|
626
|
+
"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"]
|
|
627
|
+
}
|
|
628
|
+
},
|
|
627
629
|
"nano-banana-pro": {
|
|
628
630
|
"aspect_ratio": {
|
|
629
631
|
"enum": ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "auto"]
|
|
@@ -725,7 +727,7 @@ CONTRACT = {
|
|
|
725
727
|
}
|
|
726
728
|
},
|
|
727
729
|
"seedance/text-to-video": {
|
|
728
|
-
"models": ["seedance-1.5-pro", "seedance-2.0", "seedance-2.0-fast", "seedance-v1-lite", "seedance-v1-pro", "seedance-v1-pro-fast"],
|
|
730
|
+
"models": ["seedance-1.5-pro", "seedance-2-mini", "seedance-2.0", "seedance-2.0-fast", "seedance-v1-lite", "seedance-v1-pro", "seedance-v1-pro-fast"],
|
|
729
731
|
"fields_by_model": {
|
|
730
732
|
"seedance-1.5-pro": {
|
|
731
733
|
"aspect_ratio": {
|
|
@@ -735,12 +737,20 @@ CONTRACT = {
|
|
|
735
737
|
"enum": ["480p", "720p", "1080p"]
|
|
736
738
|
}
|
|
737
739
|
},
|
|
740
|
+
"seedance-2-mini": {
|
|
741
|
+
"aspect_ratio": {
|
|
742
|
+
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"]
|
|
743
|
+
},
|
|
744
|
+
"output_resolution": {
|
|
745
|
+
"enum": ["480p", "720p"]
|
|
746
|
+
}
|
|
747
|
+
},
|
|
738
748
|
"seedance-2.0": {
|
|
739
749
|
"aspect_ratio": {
|
|
740
750
|
"enum": ["1:1", "4:3", "3:4", "16:9", "9:16", "21:9", "auto"]
|
|
741
751
|
},
|
|
742
752
|
"output_resolution": {
|
|
743
|
-
"enum": ["480p", "720p", "1080p"]
|
|
753
|
+
"enum": ["480p", "720p", "1080p", "4k"]
|
|
744
754
|
}
|
|
745
755
|
},
|
|
746
756
|
"seedance-2.0-fast": {
|
|
@@ -1162,7 +1172,11 @@ CONTRACT = {
|
|
|
1162
1172
|
"suno/replace-section": {
|
|
1163
1173
|
"models": [],
|
|
1164
1174
|
"fields_by_model": {
|
|
1165
|
-
"_": {
|
|
1175
|
+
"_": {
|
|
1176
|
+
"model": {
|
|
1177
|
+
"enum": ["suno-v4", "suno-v4.5", "suno-v4.5-all", "suno-v4.5-plus", "suno-v5", "suno-v5.5"]
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1166
1180
|
}
|
|
1167
1181
|
},
|
|
1168
1182
|
"suno/separate-audio-stems": {
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -77,7 +78,12 @@ class HttpClient:
|
|
|
77
78
|
raise NetworkError(str(exc))
|
|
78
79
|
|
|
79
80
|
if response.is_success:
|
|
80
|
-
|
|
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
|
|
81
87
|
|
|
82
88
|
error = error_from_response(response)
|
|
83
89
|
if self._retryable(method, response.status_code) and retries < max_retries:
|
|
@@ -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
|
|
@@ -8,6 +8,7 @@ from . import polling
|
|
|
8
8
|
from .errors import ValidationError
|
|
9
9
|
from .models import BaseModel, TaskResponse
|
|
10
10
|
from .options import PollingOptions, RequestOptions
|
|
11
|
+
from .response import ApiResponse
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class Resource:
|
|
@@ -33,7 +34,11 @@ class Resource:
|
|
|
33
34
|
response_class: Optional[type] = None,
|
|
34
35
|
) -> Any:
|
|
35
36
|
response = self._http.request(method, path, body=body, options=options)
|
|
36
|
-
|
|
37
|
+
payload = response.body if isinstance(response, ApiResponse) else response
|
|
38
|
+
result = BaseModel.coerce(payload, as_=response_class or type(self).RESPONSE_CLASS)
|
|
39
|
+
if isinstance(response, ApiResponse):
|
|
40
|
+
self._attach_response_headers(result, response.response_headers)
|
|
41
|
+
return result
|
|
37
42
|
|
|
38
43
|
@staticmethod
|
|
39
44
|
def _compact_params(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -61,10 +66,13 @@ class Resource:
|
|
|
61
66
|
def _validate_contract(self, schema: Dict[str, Any], params: Dict[str, Any]) -> None:
|
|
62
67
|
model = params.get("model")
|
|
63
68
|
models = schema.get("models", [])
|
|
64
|
-
if
|
|
65
|
-
|
|
69
|
+
if models:
|
|
70
|
+
if model not in models:
|
|
71
|
+
raise ValidationError(f"model must be one of: {', '.join(sorted(models))}")
|
|
72
|
+
fields = schema.get("fields_by_model", {}).get(model, {})
|
|
73
|
+
else:
|
|
74
|
+
fields = schema.get("fields_by_model", {}).get("_", {})
|
|
66
75
|
|
|
67
|
-
fields = schema.get("fields_by_model", {}).get(model, {})
|
|
68
76
|
for field, rules in fields.items():
|
|
69
77
|
self._validate_schema_field(params, field, rules)
|
|
70
78
|
|
|
@@ -196,4 +204,14 @@ class Resource:
|
|
|
196
204
|
return response
|
|
197
205
|
|
|
198
206
|
payload = response.to_dict() if isinstance(response, BaseModel) else response
|
|
199
|
-
|
|
207
|
+
completed = completed_class.from_dict(payload)
|
|
208
|
+
if isinstance(response, BaseModel):
|
|
209
|
+
completed._with_response_headers(response.response_headers)
|
|
210
|
+
return completed
|
|
211
|
+
|
|
212
|
+
def _attach_response_headers(self, result: Any, headers: Any) -> None:
|
|
213
|
+
if isinstance(result, BaseModel):
|
|
214
|
+
result._with_response_headers(headers)
|
|
215
|
+
elif isinstance(result, list):
|
|
216
|
+
for item in result:
|
|
217
|
+
self._attach_response_headers(item, headers)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Response metadata containers shared by transports and typed models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResponseHeaders(Mapping[str, str]):
|
|
10
|
+
"""Case-insensitive response headers."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, headers: Optional[Mapping[str, Any]] = None) -> None:
|
|
13
|
+
self._headers = {str(key).lower(): str(value) for key, value in (headers or {}).items()}
|
|
14
|
+
|
|
15
|
+
def __getitem__(self, key: str) -> str:
|
|
16
|
+
return self._headers[str(key).lower()]
|
|
17
|
+
|
|
18
|
+
def __iter__(self) -> Iterator[str]:
|
|
19
|
+
return iter(self._headers)
|
|
20
|
+
|
|
21
|
+
def __len__(self) -> int:
|
|
22
|
+
return len(self._headers)
|
|
23
|
+
|
|
24
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
25
|
+
return self._headers.get(str(key).lower(), default)
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, str]:
|
|
28
|
+
return dict(self._headers)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ApiResponse(dict[str, Any]):
|
|
32
|
+
"""Parsed response body plus HTTP response headers.
|
|
33
|
+
|
|
34
|
+
The object delegates common body access so existing direct transport users
|
|
35
|
+
that index the returned JSON body keep working.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, body: Mapping[str, Any] | Sequence[Any], headers: Optional[Mapping[str, Any]] = None) -> None:
|
|
39
|
+
super().__init__(body if isinstance(body, Mapping) else {})
|
|
40
|
+
self.body = self if isinstance(body, Mapping) else body
|
|
41
|
+
self.response_headers = headers if isinstance(headers, ResponseHeaders) else ResponseHeaders(headers)
|
|
42
|
+
self.headers = self.response_headers
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, key: Any) -> Any:
|
|
45
|
+
if self.body is not self:
|
|
46
|
+
return self.body[key] # type: ignore[index]
|
|
47
|
+
return super().__getitem__(key)
|
|
48
|
+
|
|
49
|
+
def __eq__(self, other: Any) -> bool:
|
|
50
|
+
if isinstance(other, ApiResponse):
|
|
51
|
+
left = dict(self) if self.body is self else self.body
|
|
52
|
+
right = dict(other) if other.body is other else other.body
|
|
53
|
+
return left == right
|
|
54
|
+
if self.body is self:
|
|
55
|
+
return dict(self) == other
|
|
56
|
+
return self.body == other
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -4,7 +4,7 @@ import tempfile
|
|
|
4
4
|
import httpx
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
-
from runapi.core import constants, errors, http_client
|
|
7
|
+
from runapi.core import ApiResponse, constants, errors, http_client
|
|
8
8
|
from runapi.core.http_client import HttpClient
|
|
9
9
|
from runapi.core.multipart import MultipartBody, MultipartFile
|
|
10
10
|
from runapi.core.options import ClientOptions, RequestOptions
|
|
@@ -31,6 +31,55 @@ def test_returns_parsed_json_on_success():
|
|
|
31
31
|
assert client.request("get", "/api/v1/test") == {"id": "123"}
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def test_keeps_response_headers_on_success():
|
|
35
|
+
client = make_client(
|
|
36
|
+
lambda request: httpx.Response(
|
|
37
|
+
200,
|
|
38
|
+
json={"id": "task-1"},
|
|
39
|
+
headers={"X-RunAPI-Task-Id": "task-ref-1"},
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
result = client.request("post", "/api/v1/test", body={"prompt": "hello"})
|
|
43
|
+
assert result == {"id": "task-1"}
|
|
44
|
+
assert result.response_headers["X-RunAPI-Task-Id"] == "task-ref-1"
|
|
45
|
+
assert result.response_headers["x-runapi-task-id"] == "task-ref-1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_keeps_response_headers_on_successful_array_response():
|
|
49
|
+
client = make_client(
|
|
50
|
+
lambda request: httpx.Response(
|
|
51
|
+
200,
|
|
52
|
+
json=[{"id": "task-1"}],
|
|
53
|
+
headers={"X-RunAPI-Task-Id": "task-ref-1"},
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
result = client.request("get", "/api/v1/test")
|
|
58
|
+
|
|
59
|
+
assert isinstance(result, ApiResponse)
|
|
60
|
+
assert result == [{"id": "task-1"}]
|
|
61
|
+
assert result.body == [{"id": "task-1"}]
|
|
62
|
+
assert result[0] == {"id": "task-1"}
|
|
63
|
+
assert result.response_headers["x-runapi-task-id"] == "task-ref-1"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_keeps_response_headers_on_error():
|
|
67
|
+
client = make_client(
|
|
68
|
+
lambda request: httpx.Response(
|
|
69
|
+
500,
|
|
70
|
+
json={"error": "fail"},
|
|
71
|
+
headers={"X-RunAPI-Task-Id": "task-ref-1"},
|
|
72
|
+
),
|
|
73
|
+
max_retries=0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
with pytest.raises(errors.ServerError) as info:
|
|
77
|
+
client.request("get", "/api/v1/test")
|
|
78
|
+
|
|
79
|
+
assert info.value.runapi_task_id == "task-ref-1"
|
|
80
|
+
assert info.value.response_headers["x-runapi-task-id"] == "task-ref-1"
|
|
81
|
+
|
|
82
|
+
|
|
34
83
|
def test_sends_bearer_and_user_agent():
|
|
35
84
|
captured = {}
|
|
36
85
|
|
|
@@ -110,3 +110,10 @@ def test_field_inheritance_override():
|
|
|
110
110
|
with pytest.raises(ValidationError, match="value is required"):
|
|
111
111
|
Child({})
|
|
112
112
|
assert Child({"value": "ok"}).value == "ok"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_response_headers_stay_outside_serialized_body():
|
|
116
|
+
model = Sample({"id": "abc"})._with_response_headers({"X-RunAPI-Task-Id": "task-ref-1"})
|
|
117
|
+
assert model.response_header("x-runapi-task-id") == "task-ref-1"
|
|
118
|
+
assert model.runapi_task_id == "task-ref-1"
|
|
119
|
+
assert model.to_dict() == {"id": "abc"}
|
|
@@ -64,12 +64,36 @@ def test_serializes_model_details():
|
|
|
64
64
|
assert info.value.details == {"status": "failed", "error": "oops", "code": 500}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
def test_task_failed_keeps_response_headers():
|
|
68
|
+
response = TaskResponse({"status": "failed", "error": "oops"})._with_response_headers(
|
|
69
|
+
{"X-RunAPI-Task-Id": "task-ref-1"}
|
|
70
|
+
)
|
|
71
|
+
with pytest.raises(TaskFailedError) as info:
|
|
72
|
+
polling.poll_until_complete(lambda: response, options())
|
|
73
|
+
assert info.value.runapi_task_id == "task-ref-1"
|
|
74
|
+
assert info.value.response_headers["x-runapi-task-id"] == "task-ref-1"
|
|
75
|
+
|
|
76
|
+
|
|
67
77
|
def test_raises_timeout():
|
|
68
78
|
short = PollingOptions(poll_interval=0.01, max_wait=0)
|
|
69
79
|
with pytest.raises(TaskTimeoutError):
|
|
70
80
|
polling.poll_until_complete(lambda: {"status": "processing"}, short)
|
|
71
81
|
|
|
72
82
|
|
|
83
|
+
def test_task_timeout_keeps_last_response_headers():
|
|
84
|
+
short = PollingOptions(poll_interval=0.01, max_wait=0)
|
|
85
|
+
response = TaskResponse({"status": "processing"})._with_response_headers(
|
|
86
|
+
{"X-RunAPI-Task-Id": "task-ref-1"}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
with pytest.raises(TaskTimeoutError) as info:
|
|
90
|
+
polling.poll_until_complete(lambda: response, short)
|
|
91
|
+
|
|
92
|
+
assert info.value.details == {"status": "processing"}
|
|
93
|
+
assert info.value.runapi_task_id == "task-ref-1"
|
|
94
|
+
assert info.value.response_headers["x-runapi-task-id"] == "task-ref-1"
|
|
95
|
+
|
|
96
|
+
|
|
73
97
|
def test_normalizes_uppercase_status():
|
|
74
98
|
response = {"status": "COMPLETED", "images": [{"url": "u"}]}
|
|
75
99
|
assert polling.poll_until_complete(lambda: response, options()) == response
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
-
from runapi.core import BaseModel, Resource, TaskResponse, optional, required
|
|
3
|
+
from runapi.core import ApiResponse, BaseModel, Resource, TaskResponse, optional, required
|
|
4
4
|
from runapi.core.errors import ValidationError
|
|
5
5
|
from runapi.core.options import PollingOptions
|
|
6
6
|
|
|
@@ -36,6 +36,31 @@ def test_request_coerces_to_response_class():
|
|
|
36
36
|
assert result.id == "1"
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_request_attaches_response_headers_to_model():
|
|
40
|
+
resource = SampleResource(
|
|
41
|
+
FakeHttp(ApiResponse({"id": "1", "status": "pending"}, {"X-RunAPI-Task-Id": "task-ref-1"}))
|
|
42
|
+
)
|
|
43
|
+
result = resource._request("post", "/x", body={"a": 1})
|
|
44
|
+
assert isinstance(result, TaskResponse)
|
|
45
|
+
assert result.runapi_task_id == "task-ref-1"
|
|
46
|
+
assert result.response_headers["X-RunAPI-Task-Id"] == "task-ref-1"
|
|
47
|
+
assert result.to_dict() == {"id": "1", "status": "pending"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_request_attaches_response_headers_to_array_items():
|
|
51
|
+
resource = SampleResource(
|
|
52
|
+
FakeHttp(ApiResponse([{"id": "1", "status": "pending"}], {"X-RunAPI-Task-Id": "task-ref-1"}))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
result = resource._request("post", "/x", body={"a": 1})
|
|
56
|
+
|
|
57
|
+
assert len(result) == 1
|
|
58
|
+
assert isinstance(result[0], TaskResponse)
|
|
59
|
+
assert result[0].runapi_task_id == "task-ref-1"
|
|
60
|
+
assert result[0].response_headers["X-RunAPI-Task-Id"] == "task-ref-1"
|
|
61
|
+
assert result[0].to_dict() == {"id": "1", "status": "pending"}
|
|
62
|
+
|
|
63
|
+
|
|
39
64
|
def test_compact_params_drops_none_and_blank():
|
|
40
65
|
assert Resource._compact_params({"a": 1, "b": None, "c": "", "d": " ", "e": "x"}) == {"a": 1, "e": "x"}
|
|
41
66
|
|
|
@@ -84,6 +109,24 @@ def test_validate_integer_still_enforces_range_for_valid_int():
|
|
|
84
109
|
assert _run_validate({"model": "m", "duration_int": 13}) == "duration_int must be between 4 and 12"
|
|
85
110
|
|
|
86
111
|
|
|
112
|
+
def test_validate_functional_action_uses_underscore_fields():
|
|
113
|
+
resource = SampleResource(FakeHttp())
|
|
114
|
+
schema = {
|
|
115
|
+
"models": [],
|
|
116
|
+
"fields_by_model": {
|
|
117
|
+
"_": {
|
|
118
|
+
"prompt": {"required": True},
|
|
119
|
+
"mode": {"enum": ["fast", "quality"]},
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
resource._validate_contract(schema, {"prompt": "hello", "mode": "fast"})
|
|
124
|
+
with pytest.raises(ValidationError, match="prompt is required"):
|
|
125
|
+
resource._validate_contract(schema, {"mode": "fast"})
|
|
126
|
+
with pytest.raises(ValidationError, match="mode must be one of: fast, quality"):
|
|
127
|
+
resource._validate_contract(schema, {"prompt": "hello", "mode": "slow"})
|
|
128
|
+
|
|
129
|
+
|
|
87
130
|
def test_validate_integer_rejects_bool_and_whole_float():
|
|
88
131
|
# bool is an int subclass but is not a valid integer value.
|
|
89
132
|
assert _run_validate({"model": "m", "tolerance": True}) == "tolerance must be an integer"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.1"
|
|
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
|