runapi-core 0.1.0__py3-none-any.whl

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/models.py ADDED
@@ -0,0 +1,248 @@
1
+ """Lightweight response models with typed field declarations and recursive coercion.
2
+
3
+ A faithful port of Ruby's ``Core::BaseModel``. Subclasses declare fields with
4
+ :func:`required` / :func:`optional` as class attributes; the field name is the
5
+ attribute name::
6
+
7
+ class Image(BaseModel):
8
+ url = optional(str)
9
+
10
+ class TextToImageResponse(TaskResponse):
11
+ id = required(str)
12
+ images = optional([lambda: Image])
13
+
14
+ Declared fields are coerced and enum-validated on construction. Undeclared
15
+ fields are preserved and exposed via attribute and item access.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Callable, Dict, List, Optional, Type, Union
21
+
22
+ from .errors import ValidationError
23
+
24
+ # A field's declared type. ``None`` means "untyped" (coerced dynamically); a
25
+ # class is used directly; a callable is invoked lazily to resolve a forward
26
+ # reference (e.g. ``lambda: Image``); a one-element list marks a list of items.
27
+ FieldType = Union[None, type, Callable[[], Any], List[Any]]
28
+ EnumType = Union[None, List[Any], Callable[[], List[Any]]]
29
+
30
+
31
+ class Field:
32
+ """A declared model field. Doubles as a descriptor that reads from the instance."""
33
+
34
+ def __init__(self, required: bool, type: FieldType = None, enum: EnumType = None) -> None:
35
+ self.required = required
36
+ self.enum = enum
37
+ self.name: Optional[str] = None
38
+
39
+ if isinstance(type, list):
40
+ if len(type) != 1:
41
+ raise ValueError("Array field type must contain exactly one item type")
42
+ self.type: FieldType = list
43
+ self.item_type: FieldType = type[0]
44
+ else:
45
+ self.type = type
46
+ self.item_type = None
47
+
48
+ def __set_name__(self, owner: type, name: str) -> None:
49
+ self.name = name
50
+
51
+ def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any:
52
+ if obj is None:
53
+ return self
54
+ return obj._attributes.get(self.name)
55
+
56
+
57
+ def required(type: FieldType = None, *, enum: EnumType = None) -> Field:
58
+ return Field(required=True, type=type, enum=enum)
59
+
60
+
61
+ def optional(type: FieldType = None, *, enum: EnumType = None) -> Field:
62
+ return Field(required=False, type=type, enum=enum)
63
+
64
+
65
+ def _resolve_type(type_: FieldType) -> Any:
66
+ if type_ is None:
67
+ return None
68
+ if isinstance(type_, type):
69
+ return type_
70
+ if callable(type_):
71
+ return type_()
72
+ return type_
73
+
74
+
75
+ class BaseModel:
76
+ _fields: Dict[str, Field] = {}
77
+
78
+ def __init_subclass__(cls, **kwargs: Any) -> None:
79
+ super().__init_subclass__(**kwargs)
80
+ merged: Dict[str, Field] = dict(getattr(cls, "_fields", {}))
81
+ for value in vars(cls).values():
82
+ if isinstance(value, Field) and value.name is not None:
83
+ merged[value.name] = value
84
+ cls._fields = merged
85
+
86
+ def __init__(self, attributes: Optional[Dict[str, Any]] = None) -> None:
87
+ source = self._normalize_input(attributes)
88
+ self._attributes: Dict[str, Any] = {}
89
+
90
+ for field in self._fields.values():
91
+ if field.name in source:
92
+ value = self._coerce_declared_value(source.pop(field.name), field)
93
+ self._attributes[field.name] = value
94
+ elif field.required:
95
+ raise ValidationError(f"{field.name} is required")
96
+
97
+ for key, value in source.items():
98
+ self._attributes[key] = self._coerce_dynamic_value(value)
99
+
100
+ # --- construction helpers -------------------------------------------------
101
+
102
+ @classmethod
103
+ def from_dict(cls, payload: Any) -> "BaseModel":
104
+ if isinstance(payload, cls):
105
+ return payload
106
+ if not isinstance(payload, dict):
107
+ raise TypeError(f"Expected dict for {cls.__name__}, got {type(payload).__name__}")
108
+ return cls(payload)
109
+
110
+ @classmethod
111
+ def coerce(cls, value: Any, as_: FieldType = None) -> Any:
112
+ target = _resolve_type(as_) or DynamicModel
113
+
114
+ if value is None:
115
+ return None
116
+ if isinstance(value, BaseModel):
117
+ if isinstance(target, type) and issubclass(target, BaseModel) and not isinstance(value, target):
118
+ return target.from_dict(value.to_dict())
119
+ return value
120
+ if isinstance(value, dict):
121
+ model = target if (isinstance(target, type) and issubclass(target, BaseModel)) else DynamicModel
122
+ return model.from_dict(value)
123
+ if isinstance(value, list):
124
+ return [cls.coerce(item, as_=target) for item in value]
125
+ return value
126
+
127
+ # --- access ---------------------------------------------------------------
128
+
129
+ def __getattr__(self, name: str) -> Any:
130
+ attributes = self.__dict__.get("_attributes", {})
131
+ if name in attributes:
132
+ return attributes[name]
133
+ raise AttributeError(name)
134
+
135
+ def __getitem__(self, key: Any) -> Any:
136
+ return self._attributes.get(str(key))
137
+
138
+ def dig(self, *keys: Any) -> Any:
139
+ current: Any = self
140
+ for key in keys:
141
+ if isinstance(current, BaseModel):
142
+ current = current[key]
143
+ elif isinstance(current, dict):
144
+ current = current.get(key)
145
+ elif isinstance(current, list):
146
+ current = current[key] if isinstance(key, int) else None
147
+ else:
148
+ return None
149
+ if current is None:
150
+ return None
151
+ return current
152
+
153
+ def to_dict(self) -> Dict[str, Any]:
154
+ return {key: self._serialize(value) for key, value in self._attributes.items()}
155
+
156
+ def __eq__(self, other: Any) -> bool:
157
+ if isinstance(other, BaseModel):
158
+ return self.to_dict() == other.to_dict()
159
+ if isinstance(other, dict):
160
+ return self.to_dict() == _stringify_keys(other)
161
+ return NotImplemented
162
+
163
+ def __repr__(self) -> str:
164
+ return f"{type(self).__name__}({self._attributes!r})"
165
+
166
+ # --- internals ------------------------------------------------------------
167
+
168
+ def _coerce_declared_value(self, value: Any, field: Field) -> Any:
169
+ if field.type is list and isinstance(value, list):
170
+ coerced: Any = [self._coerce_with_type(item, field.item_type) for item in value]
171
+ else:
172
+ coerced = self._coerce_with_type(value, field.type)
173
+ self._validate_enum(field, coerced)
174
+ return coerced
175
+
176
+ def _coerce_with_type(self, value: Any, type_: FieldType) -> Any:
177
+ if type_ is None:
178
+ return self._coerce_dynamic_value(value)
179
+
180
+ resolved = _resolve_type(type_)
181
+ if resolved is None:
182
+ return self._coerce_dynamic_value(value)
183
+ if isinstance(resolved, type) and issubclass(resolved, BaseModel) and isinstance(value, dict):
184
+ return resolved.from_dict(value)
185
+ return value
186
+
187
+ def _coerce_dynamic_value(self, value: Any) -> Any:
188
+ if isinstance(value, dict):
189
+ return DynamicModel.from_dict(value)
190
+ if isinstance(value, list):
191
+ return [self._coerce_dynamic_value(item) for item in value]
192
+ return value
193
+
194
+ def _validate_enum(self, field: Field, value: Any) -> None:
195
+ if value is None or field.enum is None:
196
+ return
197
+ allowed = field.enum() if callable(field.enum) else field.enum
198
+ if not allowed:
199
+ return
200
+
201
+ candidates = value if isinstance(value, list) else [value]
202
+ for item in candidates:
203
+ if not any(option == item or str(option) == str(item) for option in allowed):
204
+ joined = ", ".join(str(option) for option in allowed)
205
+ raise ValidationError(f"Invalid {field.name}: {item}. Must be one of: {joined}")
206
+
207
+ def _serialize(self, value: Any) -> Any:
208
+ if isinstance(value, BaseModel):
209
+ return value.to_dict()
210
+ if isinstance(value, list):
211
+ return [self._serialize(item) for item in value]
212
+ return value
213
+
214
+ @staticmethod
215
+ def _normalize_input(attributes: Optional[Dict[str, Any]]) -> Dict[str, Any]:
216
+ if attributes is None:
217
+ return {}
218
+ if not isinstance(attributes, dict):
219
+ raise TypeError(f"Expected dict, got {type(attributes).__name__}")
220
+ return {str(key): value for key, value in attributes.items()}
221
+
222
+
223
+ class DynamicModel(BaseModel):
224
+ """Generic response model used when no typed model is provided."""
225
+
226
+
227
+ def _stringify_keys(value: Any) -> Any:
228
+ if isinstance(value, dict):
229
+ return {str(key): _stringify_keys(item) for key, item in value.items()}
230
+ if isinstance(value, list):
231
+ return [_stringify_keys(item) for item in value]
232
+ return value
233
+
234
+
235
+ class TaskResponse(BaseModel):
236
+ """Typed response for async task operations. Extra fields are preserved."""
237
+
238
+ class Status:
239
+ PENDING = "pending"
240
+ PROCESSING = "processing"
241
+ COMPLETED = "completed"
242
+ FAILED = "failed"
243
+
244
+ ALL = [PENDING, PROCESSING, COMPLETED, FAILED]
245
+
246
+ id = optional(str)
247
+ status = optional(str)
248
+ error = optional(str)
@@ -0,0 +1,23 @@
1
+ """Multipart upload payloads."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ @dataclass
8
+ class MultipartFile:
9
+ path: str
10
+ filename: Optional[str] = None
11
+ content_type: Optional[str] = None
12
+
13
+
14
+ class MultipartBody:
15
+ """A multipart/form-data request body of plain fields plus file parts."""
16
+
17
+ def __init__(
18
+ self,
19
+ fields: Optional[Dict[str, Any]] = None,
20
+ files: Optional[Dict[str, MultipartFile]] = None,
21
+ ) -> None:
22
+ self.fields: Dict[str, Any] = {str(key): value for key, value in (fields or {}).items()}
23
+ self.files: Dict[str, MultipartFile] = {str(key): value for key, value in (files or {}).items()}
runapi/core/options.py ADDED
@@ -0,0 +1,54 @@
1
+ """Client, request, and polling option dataclasses."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, Optional
5
+
6
+ from . import config, constants
7
+
8
+
9
+ @dataclass
10
+ class ClientOptions:
11
+ """Configuration for an API client.
12
+
13
+ ``http_client`` lets callers inject a custom transport (for tests or
14
+ advanced use). When set, the SDK delegates all HTTP calls to it instead of
15
+ building its own :class:`HttpClient`. It must expose
16
+ ``request(method, path, body=None, options=None)``.
17
+ """
18
+
19
+ api_key: Optional[str] = None
20
+ base_url: Optional[str] = None
21
+ timeout: Optional[int] = None
22
+ max_retries: Optional[int] = None
23
+ retry_base_delay: Optional[float] = None
24
+ retry_max_delay: Optional[float] = None
25
+ http_client: Optional[Any] = None
26
+
27
+ def __post_init__(self) -> None:
28
+ if self.base_url is None:
29
+ self.base_url = config.base_url
30
+ if self.timeout is None:
31
+ self.timeout = constants.HTTP_REQUEST_TIMEOUT
32
+ if self.max_retries is None:
33
+ self.max_retries = constants.MAX_RETRIES
34
+ if self.retry_base_delay is None:
35
+ self.retry_base_delay = constants.RETRY_BASE_DELAY
36
+ if self.retry_max_delay is None:
37
+ self.retry_max_delay = constants.RETRY_MAX_DELAY
38
+
39
+
40
+ @dataclass
41
+ class RequestOptions:
42
+ """Per-request overrides of client-level defaults."""
43
+
44
+ headers: Optional[Dict[str, str]] = None
45
+ timeout: Optional[int] = None
46
+ max_retries: Optional[int] = None
47
+
48
+
49
+ @dataclass
50
+ class PollingOptions:
51
+ """Options for polling async task completion."""
52
+
53
+ poll_interval: int = field(default=constants.POLLING_INTERVAL)
54
+ max_wait: int = field(default=constants.POLLING_MAX_WAIT)
runapi/core/polling.py ADDED
@@ -0,0 +1,55 @@
1
+ """Polling loop for async task completion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Callable, Optional
7
+
8
+ from .errors import TaskFailedError, TaskTimeoutError
9
+ from .models import BaseModel
10
+ from .options import PollingOptions
11
+
12
+ ACTIVE_STATUSES = ("pending", "processing")
13
+
14
+
15
+ def poll_until_complete(fetch: Callable[[], Any], options: Optional[PollingOptions] = None) -> Any:
16
+ """Call ``fetch`` repeatedly until the task completes.
17
+
18
+ Returns the completed response. Raises :class:`TaskFailedError` on a failed
19
+ or unknown status, and :class:`TaskTimeoutError` once ``max_wait`` elapses.
20
+ """
21
+ if options is None:
22
+ options = PollingOptions()
23
+
24
+ deadline = time.monotonic() + options.max_wait
25
+
26
+ while True:
27
+ response = fetch()
28
+ status = str(_value_for(response, "status") or "").lower()
29
+
30
+ if status == "completed":
31
+ return response
32
+
33
+ if status == "failed":
34
+ message = _value_for(response, "error") or "Task failed"
35
+ raise TaskFailedError(message, details=_details_for(response))
36
+
37
+ if time.monotonic() >= deadline:
38
+ raise TaskTimeoutError(f"Task polling timed out after {options.max_wait}s")
39
+
40
+ if status not in ACTIVE_STATUSES:
41
+ raise TaskFailedError(f"Unknown task status: {status}", details=_details_for(response))
42
+
43
+ time.sleep(options.poll_interval)
44
+
45
+
46
+ def _value_for(response: Any, key: str) -> Any:
47
+ if isinstance(response, BaseModel):
48
+ return response[key]
49
+ if isinstance(response, dict):
50
+ return response.get(key)
51
+ return None
52
+
53
+
54
+ def _details_for(response: Any) -> Any:
55
+ return response.to_dict() if isinstance(response, BaseModel) else response
runapi/core/py.typed ADDED
File without changes
@@ -0,0 +1,65 @@
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)
runapi/core/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: runapi-core
3
+ Version: 0.1.0
4
+ Summary: Core module for the RunAPI Python SDK
5
+ Project-URL: Homepage, https://runapi.ai
6
+ Project-URL: Documentation, https://runapi.ai/docs#sdk
7
+ Author-email: RunAPI <contact@runapi.ai>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,api,runapi,sdk
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: httpx>=0.27
12
+ Description-Content-Type: text/markdown
13
+
14
+ # RunAPI Core Python SDK
15
+
16
+ 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`.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install runapi-core
22
+ ```
23
+
24
+ ## Notes
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:
27
+
28
+ ```python
29
+ import runapi.core as runapi
30
+
31
+ runapi.configure(api_key="sk-...") # or set RUNAPI_API_KEY in the environment
32
+ ```
33
+
34
+ ```python
35
+ from runapi.core import FilesClient
36
+
37
+ files = FilesClient() # reads RUNAPI_API_KEY, or pass api_key="sk-..."
38
+ uploaded = files.create(file="./input.png")
39
+ print(uploaded.url)
40
+ ```
41
+
42
+ Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
43
+
44
+ ## License
45
+
46
+ Licensed under the Apache License, Version 2.0.
@@ -0,0 +1,18 @@
1
+ runapi/core/__init__.py,sha256=hJ_3FVD39iVgkQ9QpFMImEdi9q7Ge_xj1fkfBdDZUJg,1694
2
+ runapi/core/auth.py,sha256=woKMW_oidp9xEmPHb2-7QVvNc8aIitycuIaTdv2Pacw,1053
3
+ runapi/core/config.py,sha256=9HoDPKzkV1Zhwi8qdZPuB_MQhEv59KTW-rly-Xre0YY,728
4
+ runapi/core/constants.py,sha256=peWEesxVWjyGMVbBzXBr4C7ZgWjzXKgh4T4q7EI-IwQ,400
5
+ runapi/core/contract_gen.py,sha256=9gfXFv5n05AHCpQobjcaB-HEou7Zga4vywxFdEONe9Q,46457
6
+ runapi/core/errors.py,sha256=g4hqy_FNeTMi_pvMecmu_jkwlBNPpnKDinTjqJjAuBE,7802
7
+ runapi/core/files.py,sha256=hUNsXBUwYRysWlOVpO18cHuaowj6CwXeAE6i-6psrgg,3507
8
+ runapi/core/http_client.py,sha256=Yh1ZlGHiBX0tZfXuwMXOl0ccD8WAvsEoy7n_aLmhOZI,4566
9
+ runapi/core/models.py,sha256=useACuEJ3Ob5QALyazBN7AVyFSw8GEPp94efRa1KUEs,9007
10
+ runapi/core/multipart.py,sha256=Hv6T6FCnTkB4KEC_gIicK9zppCA6_jseb4RKRrLdtHw,687
11
+ runapi/core/options.py,sha256=MlXzPWEdyuL1-JcUWfszfWZ-5MuaEHUelEatZzHj7f0,1719
12
+ runapi/core/polling.py,sha256=v2_tjrDGlRjn6LWcshQgIdLsXoJX4AjQQ4YSy0dUcHE,1735
13
+ runapi/core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ runapi/core/resource.py,sha256=ZIxRA2izZeX6cnV_6_417kR4F1qW2IE7rfaGUrQRULo,2308
15
+ runapi/core/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
16
+ runapi_core-0.1.0.dist-info/METADATA,sha256=WvqR5b2-S9VB9kunCfH2KzmMwL24aVamJjwGbQIOyjo,1459
17
+ runapi_core-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
18
+ runapi_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any