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.
Files changed (30) hide show
  1. {runapi_core-0.1.0 → runapi_core-0.1.1}/PKG-INFO +32 -1
  2. runapi_core-0.1.1/README.md +64 -0
  3. {runapi_core-0.1.0 → runapi_core-0.1.1}/pyproject.toml +3 -0
  4. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/contract_gen.py +22 -7
  5. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/files.py +52 -15
  6. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/http_client.py +18 -0
  7. runapi_core-0.1.1/src/runapi/core/resource.py +199 -0
  8. runapi_core-0.1.1/src/runapi/core/version.py +1 -0
  9. runapi_core-0.1.1/tests/test_files.py +170 -0
  10. {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_resource.py +45 -0
  11. runapi_core-0.1.0/README.md +0 -33
  12. runapi_core-0.1.0/src/runapi/core/resource.py +0 -65
  13. runapi_core-0.1.0/src/runapi/core/version.py +0 -1
  14. runapi_core-0.1.0/tests/test_files.py +0 -113
  15. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/__init__.py +0 -0
  16. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/auth.py +0 -0
  17. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/config.py +0 -0
  18. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/constants.py +0 -0
  19. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/errors.py +0 -0
  20. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/models.py +0 -0
  21. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/multipart.py +0 -0
  22. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/options.py +0 -0
  23. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/polling.py +0 -0
  24. {runapi_core-0.1.0 → runapi_core-0.1.1}/src/runapi/core/py.typed +0 -0
  25. {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_auth.py +0 -0
  26. {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_config.py +0 -0
  27. {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_errors.py +0 -0
  28. {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_http_client.py +0 -0
  29. {runapi_core-0.1.0 → runapi_core-0.1.1}/tests/test_models.py +0 -0
  30. {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.0
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.
@@ -28,3 +28,6 @@ package = true
28
28
 
29
29
  [dependency-groups]
30
30
  dev = ["pytest>=8", "httpx>=0.27"]
31
+
32
+ [tool.runapi]
33
+ slug = "core"
@@ -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 to register instead of uploading a local file.
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
- body: Any = self._multipart_body(file, file_name)
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
- def _multipart_body(self, file: Any, file_name: Optional[str]) -> MultipartBody:
83
- path = self._file_path(file)
84
- filename = file_name or os.path.basename(path)
85
- return MultipartBody(
86
- fields=self._compact_params({"file_name": file_name}),
87
- files={"file": MultipartFile(path=path, filename=filename)},
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"}]})
@@ -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=" ")