runapi-core 0.1.1__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.
Files changed (28) hide show
  1. {runapi_core-0.1.1 → runapi_core-0.1.2}/PKG-INFO +20 -20
  2. {runapi_core-0.1.1 → runapi_core-0.1.2}/README.md +19 -19
  3. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/__init__.py +3 -0
  4. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/contract_gen.py +6 -1
  5. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/errors.py +19 -1
  6. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/http_client.py +7 -1
  7. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/models.py +18 -1
  8. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/polling.py +19 -3
  9. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/resource.py +17 -2
  10. runapi_core-0.1.2/src/runapi/core/response.py +56 -0
  11. runapi_core-0.1.2/src/runapi/core/version.py +1 -0
  12. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_http_client.py +50 -1
  13. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_models.py +7 -0
  14. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_polling.py +24 -0
  15. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_resource.py +26 -1
  16. runapi_core-0.1.1/src/runapi/core/version.py +0 -1
  17. {runapi_core-0.1.1 → runapi_core-0.1.2}/pyproject.toml +0 -0
  18. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/auth.py +0 -0
  19. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/config.py +0 -0
  20. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/constants.py +0 -0
  21. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/files.py +0 -0
  22. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/multipart.py +0 -0
  23. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/options.py +0 -0
  24. {runapi_core-0.1.1 → runapi_core-0.1.2}/src/runapi/core/py.typed +0 -0
  25. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_auth.py +0 -0
  26. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_config.py +0 -0
  27. {runapi_core-0.1.1 → runapi_core-0.1.2}/tests/test_errors.py +0 -0
  28. {runapi_core-0.1.1 → runapi_core-0.1.2}/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.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 model SDKs share. Configure it globally or per client:
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 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.
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 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,
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
- response.raise_for_status()
59
- runapi_task_id = response.headers.get("X-RunAPI-Task-Id")
60
- body = response.json()
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 model SDKs share. Configure it globally or per client:
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 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.
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 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,
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
- response.raise_for_status()
46
- runapi_task_id = response.headers.get("X-RunAPI-Task-Id")
47
- body = response.json()
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",
@@ -603,7 +603,7 @@ CONTRACT = {
603
603
  }
604
604
  },
605
605
  "nano-banana/text-to-image": {
606
- "models": ["nano-banana", "nano-banana-2", "nano-banana-pro"],
606
+ "models": ["nano-banana", "nano-banana-2", "nano-banana-2-lite", "nano-banana-pro"],
607
607
  "fields_by_model": {
608
608
  "nano-banana": {
609
609
  "aspect_ratio": {
@@ -624,6 +624,11 @@ CONTRACT = {
624
624
  "enum": ["1k", "2k", "4k"]
625
625
  }
626
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
+ },
627
632
  "nano-banana-pro": {
628
633
  "aspect_ratio": {
629
634
  "enum": ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "auto"]
@@ -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] = {"status": status, "request_id": request_id, "details": parsed_body}
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
- return self._parse_body(response.text)
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(message, details=_details_for(response))
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(f"Task polling timed out after {options.max_wait}s")
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(f"Unknown task status: {status}", details=_details_for(response))
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
- return BaseModel.coerce(response, as_=response_class or type(self).RESPONSE_CLASS)
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]:
@@ -196,4 +201,14 @@ class Resource:
196
201
  return response
197
202
 
198
203
  payload = response.to_dict() if isinstance(response, BaseModel) else response
199
- return completed_class.from_dict(payload)
204
+ completed = completed_class.from_dict(payload)
205
+ if isinstance(response, BaseModel):
206
+ completed._with_response_headers(response.response_headers)
207
+ return completed
208
+
209
+ def _attach_response_headers(self, result: Any, headers: Any) -> None:
210
+ if isinstance(result, BaseModel):
211
+ result._with_response_headers(headers)
212
+ elif isinstance(result, list):
213
+ for item in result:
214
+ 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.2"
@@ -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
 
@@ -1 +0,0 @@
1
- __version__ = "0.1.1"
File without changes