runapi-core 0.9.0__tar.gz → 0.10.0__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 (36) hide show
  1. {runapi_core-0.9.0 → runapi_core-0.10.0}/PKG-INFO +2 -2
  2. {runapi_core-0.9.0 → runapi_core-0.10.0}/pyproject.toml +1 -1
  3. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/__init__.py +0 -2
  4. runapi_core-0.10.0/src/runapi/core/resource.py +192 -0
  5. runapi_core-0.10.0/src/runapi/core/version.py +1 -0
  6. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_files.py +0 -20
  7. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_resource.py +0 -108
  8. runapi_core-0.9.0/src/runapi/core/contract_gen.py +0 -2560
  9. runapi_core-0.9.0/src/runapi/core/resource.py +0 -393
  10. runapi_core-0.9.0/src/runapi/core/version.py +0 -1
  11. {runapi_core-0.9.0 → runapi_core-0.10.0}/README.md +0 -0
  12. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/account.py +0 -0
  13. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/auth.py +0 -0
  14. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/config.py +0 -0
  15. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/constants.py +0 -0
  16. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/errors.py +0 -0
  17. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/files.py +0 -0
  18. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/http_client.py +0 -0
  19. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/models.py +0 -0
  20. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/multipart.py +0 -0
  21. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/options.py +0 -0
  22. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/polling.py +0 -0
  23. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/pricing.py +0 -0
  24. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/provider_client.py +0 -0
  25. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/py.typed +0 -0
  26. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/response.py +0 -0
  27. {runapi_core-0.9.0 → runapi_core-0.10.0}/src/runapi/core/uploads.py +0 -0
  28. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_account.py +0 -0
  29. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_auth.py +0 -0
  30. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_config.py +0 -0
  31. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_errors.py +0 -0
  32. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_http_client.py +0 -0
  33. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_models.py +0 -0
  34. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_polling.py +0 -0
  35. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_pricing.py +0 -0
  36. {runapi_core-0.9.0 → runapi_core-0.10.0}/tests/test_uploads.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runapi-core
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: RunAPI core SDK for JavaScript, Python, Ruby, Go, Java, and PHP
5
5
  Project-URL: Homepage, https://runapi.ai/models
6
6
  Project-URL: Documentation, https://runapi.ai/docs/resources/sdks
7
7
  Project-URL: Source, https://github.com/runapi-ai/core-sdk
8
8
  Project-URL: Issues, https://github.com/runapi-ai/core-sdk/issues
9
9
  Project-URL: Changelog, https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md
10
- Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.9.0
10
+ Project-URL: Release Notes, https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.10.0
11
11
  Author-email: RunAPI <contact@runapi.ai>
12
12
  License-Expression: Apache-2.0
13
13
  Keywords: api,core,golang,gradle,java,maven,pypi,python,ruby,runapi,runapi-ai,sdk,typescript
@@ -19,7 +19,7 @@ Documentation = "https://runapi.ai/docs/resources/sdks"
19
19
  Source = "https://github.com/runapi-ai/core-sdk"
20
20
  Issues = "https://github.com/runapi-ai/core-sdk/issues"
21
21
  Changelog = "https://github.com/runapi-ai/core-sdk/blob/main/CHANGELOG.md"
22
- "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.9.0"
22
+ "Release Notes" = "https://github.com/runapi-ai/core-sdk/releases/tag/python%2Fv0.10.0"
23
23
 
24
24
  [tool.hatch.version]
25
25
  path = "src/runapi/core/version.py"
@@ -8,7 +8,6 @@ from . import config, errors, polling
8
8
  from .auth import resolve_api_key, resolve_optional_api_key
9
9
  from .account import Account, AccountBalanceResponse, AccountInfoResponse, AccountRecord
10
10
  from .config import configure
11
- from .contract_gen import CONTRACT
12
11
  from .errors import (
13
12
  AuthenticationError,
14
13
  ConflictError,
@@ -52,7 +51,6 @@ __all__ = [
52
51
  "configure",
53
52
  "errors",
54
53
  "polling",
55
- "CONTRACT",
56
54
  "resolve_api_key",
57
55
  "resolve_optional_api_key",
58
56
  "Account",
@@ -0,0 +1,192 @@
1
+ """Base class for API resources: request coercion, param helpers, polling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from typing import Any, Callable, Dict, Optional
7
+ from uuid import uuid4
8
+
9
+ from . import polling
10
+ from .errors import TaskFailedError, ValidationError, _parse_retry_after, error_from_response_data
11
+ from .models import BaseModel, TaskResult, TaskResponse
12
+ from .options import PollingOptions, RequestOptions
13
+ from .response import ApiResponse
14
+
15
+
16
+ class Resource:
17
+ """Shared behavior for resource classes.
18
+
19
+ Subclasses set ``RESPONSE_CLASS`` (the typed model for responses) and
20
+ optionally ``COMPLETED_RESPONSE_CLASS`` (a narrowed model that ``run()``
21
+ re-coerces to once the task completes).
22
+ """
23
+
24
+ RESPONSE_CLASS: Optional[type] = TaskResponse
25
+ COMPLETED_RESPONSE_CLASS: Optional[type] = None
26
+
27
+ def __init__(self, http: Any) -> None:
28
+ self._http = http
29
+
30
+ def _request(
31
+ self,
32
+ method: str,
33
+ path: str,
34
+ body: Any = None,
35
+ options: Optional[RequestOptions] = None,
36
+ response_class: Optional[type] = None,
37
+ ) -> Any:
38
+ response = self._http.request(method, path, body=body, options=options)
39
+ return self._coerce_response(response, response_class)
40
+
41
+ def _coerce_response(self, response: Any, response_class: Optional[type] = None) -> Any:
42
+ payload = response.body if isinstance(response, ApiResponse) else response
43
+ target = response_class or type(self).RESPONSE_CLASS
44
+ result = payload if target is None else BaseModel.coerce(payload, as_=target)
45
+ if isinstance(response, ApiResponse):
46
+ self._attach_response_headers(result, response.response_headers)
47
+ return result
48
+
49
+ def _run_hybrid(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ body: Any = None,
54
+ options: Optional[RequestOptions] = None,
55
+ polling_options: Optional[PollingOptions] = None,
56
+ ) -> Any:
57
+ """Run a terminal-or-accepted Task endpoint through one stable API."""
58
+ request_options = self._with_idempotency_key(options) if method.upper() == "POST" else options
59
+ response = self._http.request(method, path, body=body, options=request_options)
60
+ if isinstance(response, ApiResponse) and response.status_code == 202:
61
+ location = response.response_headers.get("Location")
62
+ if not location:
63
+ raise TaskFailedError(
64
+ "Accepted Task response is missing Location",
65
+ response_headers=response.response_headers,
66
+ )
67
+ return self.subscribe(
68
+ location,
69
+ options=request_options,
70
+ polling_options=polling_options,
71
+ initial_delay=self._retry_after(response.response_headers),
72
+ )
73
+ return self._coerce_response(response)
74
+
75
+ def subscribe(
76
+ self,
77
+ location: str,
78
+ *,
79
+ options: Optional[RequestOptions] = None,
80
+ polling_options: Optional[PollingOptions] = None,
81
+ initial_delay: Optional[float] = None,
82
+ ) -> Any:
83
+ """Follow an opaque Task Result URL until its stored terminal response is ready."""
84
+ return self._poll_until_complete(
85
+ lambda: self._request("get", location, options=options, response_class=TaskResult),
86
+ polling_options,
87
+ initial_delay=initial_delay,
88
+ completed=self._decode_task_result,
89
+ failed=self._task_result_error,
90
+ )
91
+
92
+ def _with_idempotency_key(self, options: Optional[RequestOptions]) -> RequestOptions:
93
+ headers = {}
94
+ idempotency_key = None
95
+ source_headers = (options.headers or {}) if options else {}
96
+ for name, value in source_headers.items():
97
+ if name.lower() != "idempotency-key":
98
+ headers[name] = value
99
+ continue
100
+
101
+ normalized = str(value).strip()
102
+ if not normalized:
103
+ continue
104
+ if idempotency_key is not None and idempotency_key != normalized:
105
+ raise ValidationError("conflicting Idempotency-Key headers")
106
+ idempotency_key = normalized
107
+
108
+ headers["Idempotency-Key"] = idempotency_key or str(uuid4())
109
+ return replace(options, headers=headers) if options else RequestOptions(headers=headers)
110
+
111
+ def _decode_task_result(self, task: TaskResult) -> Any:
112
+ response = task.response
113
+ if response is None:
114
+ raise TaskFailedError("Completed Task is missing its terminal response", details=task.to_dict())
115
+ if not 200 <= response.status < 300:
116
+ raise self._task_result_error(task)
117
+
118
+ body = self._plain_value(response.body)
119
+ content_type = response.content_type.lower().split(";", 1)[0].strip()
120
+ if content_type == "application/json" or content_type.endswith("+json"):
121
+ response_class = type(self).RESPONSE_CLASS
122
+ result = body if response_class is None else BaseModel.coerce(body, as_=response_class)
123
+ self._attach_response_headers(result, response.headers or {})
124
+ return result
125
+ if self._textual_content_type(content_type):
126
+ if isinstance(body, bytes):
127
+ return body.decode("utf-8")
128
+ return "" if body is None else str(body)
129
+ if isinstance(body, bytes):
130
+ return body
131
+ if isinstance(body, str):
132
+ return body.encode("utf-8")
133
+ return body
134
+
135
+ def _task_result_error(self, task: TaskResult) -> Exception:
136
+ response = task.response
137
+ if response is None:
138
+ return TaskFailedError("Task failed without a terminal response", details=task.to_dict())
139
+ return error_from_response_data(
140
+ response.status,
141
+ self._plain_value(response.body),
142
+ response.headers or {},
143
+ )
144
+
145
+ def _poll_until_complete(
146
+ self,
147
+ fetch: Callable[[], Any],
148
+ polling_opts: Optional[PollingOptions] = None,
149
+ **kwargs: Any,
150
+ ) -> Any:
151
+ response = polling.poll_until_complete(fetch, polling_opts or PollingOptions(), **kwargs)
152
+
153
+ if kwargs.get("completed"):
154
+ return response
155
+
156
+ completed_class = type(self).COMPLETED_RESPONSE_CLASS
157
+ if completed_class is None or isinstance(response, completed_class):
158
+ return response
159
+
160
+ payload = response.to_dict() if isinstance(response, BaseModel) else response
161
+ completed = completed_class.from_dict(payload)
162
+ if isinstance(response, BaseModel):
163
+ completed._with_response_headers(response.response_headers)
164
+ return completed
165
+
166
+ @staticmethod
167
+ def _plain_value(value: Any) -> Any:
168
+ return value.to_dict() if isinstance(value, BaseModel) else value
169
+
170
+ @staticmethod
171
+ def _textual_content_type(content_type: str) -> bool:
172
+ return content_type.startswith("text/") or content_type in {"application/srt", "application/x-subrip"}
173
+
174
+ @staticmethod
175
+ def _retry_after(headers: Any) -> Optional[float]:
176
+ delay = _parse_retry_after(headers.get("Retry-After") if headers else None)
177
+ return delay if delay and delay > 0 else None
178
+
179
+ @staticmethod
180
+ def _compact_params(params: Dict[str, Any]) -> Dict[str, Any]:
181
+ return {
182
+ key: value
183
+ for key, value in params.items()
184
+ if not (value is None or (isinstance(value, str) and value.strip() == ""))
185
+ }
186
+
187
+ def _attach_response_headers(self, result: Any, headers: Any) -> None:
188
+ if isinstance(result, BaseModel):
189
+ result._with_response_headers(headers)
190
+ elif isinstance(result, list):
191
+ for item in result:
192
+ self._attach_response_headers(item, headers)
@@ -0,0 +1 @@
1
+ __version__ = "0.10.0"
@@ -3,8 +3,6 @@ import hashlib
3
3
  import os
4
4
  import tempfile
5
5
 
6
- import pytest
7
-
8
6
  from runapi.core import FilesClient
9
7
 
10
8
 
@@ -41,15 +39,6 @@ UPLOAD = {
41
39
  }
42
40
 
43
41
 
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
42
  def test_accepts_an_existing_http_client_without_resolving_auth_again():
54
43
  fake = FakeHttp(UPLOAD)
55
44
 
@@ -169,15 +158,6 @@ def test_create_with_pathlib_path_keeps_full_path():
169
158
  assert fake.uploads[0][2] == b"png"
170
159
 
171
160
 
172
- def test_create_rejects_blank_source():
173
- # Regression: a blank source must fail locally, not POST an empty body.
174
- client = FilesClient(api_key="k", http_client=FakeHttp(UPLOAD))
175
- with pytest.raises(ValueError):
176
- client.create(source="")
177
- with pytest.raises(ValueError):
178
- client.create(source=" ")
179
-
180
-
181
161
  def test_protocol_file_lifecycle_keeps_binary_content(tmp_path):
182
162
  path = tmp_path / "input.bin"
183
163
  path.write_bytes(b"\x00\xff\x01")
@@ -79,114 +79,6 @@ def test_compact_params_drops_none_and_blank():
79
79
  assert Resource._compact_params({"a": 1, "b": None, "c": "", "d": " ", "e": "x"}) == {"a": 1, "e": "x"}
80
80
 
81
81
 
82
- def test_validate_optional_passes_and_rejects():
83
- Resource._validate_optional({"k": "a"}, "k", ["a", "b"])
84
- Resource._validate_optional({}, "k", ["a", "b"])
85
- with pytest.raises(ValidationError, match="Invalid k"):
86
- Resource._validate_optional({"k": "z"}, "k", ["a", "b"])
87
-
88
-
89
- INT_SCHEMA = {
90
- "models": ["m"],
91
- "fields_by_model": {
92
- "m": {
93
- "duration_int": {"type": "integer", "min": 4, "max": 12},
94
- "tolerance": {"type": "integer"}}
95
- }}
96
-
97
-
98
- def _run_validate(params):
99
- resource = SampleResource(FakeHttp())
100
- try:
101
- resource._validate_contract(INT_SCHEMA, params)
102
- return ""
103
- except ValidationError as err:
104
- return str(err)
105
-
106
-
107
- def test_validate_integer_rejects_non_integer_within_range():
108
- assert _run_validate({"model": "m", "duration_int": 11.5}) == "duration_int must be an integer between 4 and 12"
109
-
110
-
111
- def test_validate_integer_runs_before_range():
112
- # A non-integer below the range reports the integer error, not the range one.
113
- assert _run_validate({"model": "m", "duration_int": 2.5}) == "duration_int must be an integer between 4 and 12"
114
-
115
-
116
- def test_validate_bare_integer_has_no_range_detail():
117
- assert _run_validate({"model": "m", "tolerance": 3.5}) == "tolerance must be an integer"
118
-
119
-
120
- def test_validate_integer_still_enforces_range_for_valid_int():
121
- assert _run_validate({"model": "m", "duration_int": 13}) == "duration_int must be between 4 and 12"
122
-
123
-
124
- def test_validate_functional_action_uses_underscore_fields():
125
- resource = SampleResource(FakeHttp())
126
- schema = {
127
- "models": [],
128
- "fields_by_model": {
129
- "_": {
130
- "prompt": {"required": True},
131
- "mode": {"enum": ["fast", "quality"]}}
132
- }}
133
- resource._validate_contract(schema, {"prompt": "hello", "mode": "fast"})
134
- with pytest.raises(ValidationError, match="prompt is required"):
135
- resource._validate_contract(schema, {"mode": "fast"})
136
- with pytest.raises(ValidationError, match="mode must be one of: fast, quality"):
137
- resource._validate_contract(schema, {"prompt": "hello", "mode": "slow"})
138
-
139
-
140
- def test_validate_integer_rejects_bool_and_whole_float():
141
- # bool is an int subclass but is not a valid integer value.
142
- assert _run_validate({"model": "m", "tolerance": True}) == "tolerance must be an integer"
143
- # Python keeps the float/int distinction: a whole-valued float is still a float.
144
- assert _run_validate({"model": "m", "tolerance": 5.0}) == "tolerance must be an integer"
145
- assert _run_validate({"model": "m", "tolerance": 5}) == ""
146
-
147
-
148
- def test_validate_boolean_enum_requires_boolean_value():
149
- schema = {"models": ["m"], "fields_by_model": {"m": {"flag": {"enum": [True, False]}}}}
150
- resource = SampleResource(FakeHttp())
151
-
152
- resource._validate_contract(schema, {"model": "m", "flag": True})
153
- resource._validate_contract(schema, {"model": "m", "flag": False})
154
- with pytest.raises(ValidationError, match="^flag must be one of: True, False$"):
155
- resource._validate_contract(schema, {"model": "m", "flag": "true"})
156
-
157
-
158
- def test_validate_array_item_count_constraints():
159
- schema = {
160
- "models": ["m"],
161
- "fields_by_model": {
162
- "m": {
163
- "reference_image_urls": {"min_items": 1, "max_items": 3}}
164
- }}
165
- resource = SampleResource(FakeHttp())
166
-
167
- with pytest.raises(ValidationError, match="^reference_image_urls must be an array$"):
168
- resource._validate_contract(schema, {"model": "m", "reference_image_urls": "image.png"})
169
- with pytest.raises(ValidationError, match="^reference_image_urls must contain between 1 and 3 items$"):
170
- resource._validate_contract(schema, {"model": "m", "reference_image_urls": []})
171
- with pytest.raises(ValidationError, match="^reference_image_urls must contain between 1 and 3 items$"):
172
- resource._validate_contract(schema, {"model": "m", "reference_image_urls": ["a", "b", "c", "d"]})
173
- with pytest.raises(ValidationError, match="^reference_image_urls must contain between 1 and 3 items$"):
174
- resource._validate_contract(schema, {"model": "m", "reference_image_urls": ("a", "b", "c", "d")})
175
-
176
- resource._validate_contract(schema, {"model": "m", "reference_image_urls": ["a", "b", "c"]})
177
- resource._validate_contract(schema, {"model": "m", "reference_image_urls": ("a", "b", "c")})
178
-
179
-
180
- def test_validate_contract_runs_rules_before_field_requirements():
181
- schema = {
182
- "models": ["m"],
183
- "rules": [{"when": {"model": "m"}, "forbidden": ["source_task_id"]}],
184
- "fields_by_model": {"m": {"source_image_urls": {"required": True}}}}
185
- resource = SampleResource(FakeHttp())
186
- with pytest.raises(ValidationError, match="source_task_id is not allowed when model is m"):
187
- resource._validate_contract(schema, {"model": "m", "source_task_id": "src_1"})
188
-
189
-
190
82
  def test_poll_recoerces_to_completed_class():
191
83
  resource = SampleResource(FakeHttp())
192
84
  response = TaskResponse({"id": "1", "status": "completed", "usage": {"cost": 0.05}, "images": [{"url": "u"}]})