waveium 0.2.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.
- waveium/__init__.py +204 -0
- waveium/_config.py +88 -0
- waveium/_http.py +362 -0
- waveium/_sse.py +497 -0
- waveium/async_client.py +92 -0
- waveium/client.py +90 -0
- waveium/exceptions.py +131 -0
- waveium/models/__init__.py +224 -0
- waveium/models/auth.py +58 -0
- waveium/models/common.py +130 -0
- waveium/models/environments.py +247 -0
- waveium/models/events.py +132 -0
- waveium/models/executions.py +145 -0
- waveium/models/files.py +158 -0
- waveium/models/organizations.py +51 -0
- waveium/models/projects.py +177 -0
- waveium/models/roles.py +48 -0
- waveium/models/scenes.py +222 -0
- waveium/models/shares.py +51 -0
- waveium/models/teams.py +90 -0
- waveium/models/users.py +88 -0
- waveium/resources/__init__.py +41 -0
- waveium/resources/_base.py +30 -0
- waveium/resources/_file_ops.py +371 -0
- waveium/resources/_share_ops.py +175 -0
- waveium/resources/_stream_ops.py +318 -0
- waveium/resources/_upload_ops.py +286 -0
- waveium/resources/auth.py +157 -0
- waveium/resources/environments.py +1047 -0
- waveium/resources/executions.py +1142 -0
- waveium/resources/organizations.py +281 -0
- waveium/resources/projects.py +559 -0
- waveium/resources/roles.py +104 -0
- waveium/resources/scenes.py +1314 -0
- waveium/resources/teams.py +351 -0
- waveium/resources/users.py +448 -0
- waveium-0.2.0.dist-info/METADATA +426 -0
- waveium-0.2.0.dist-info/RECORD +40 -0
- waveium-0.2.0.dist-info/WHEEL +4 -0
- waveium-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Reusable streaming operation mixins for resources."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
AsyncIterator,
|
|
8
|
+
Callable,
|
|
9
|
+
FrozenSet,
|
|
10
|
+
Iterator,
|
|
11
|
+
Optional,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("waveium")
|
|
15
|
+
|
|
16
|
+
from .._sse import AsyncSSEConnection, SSEConnection
|
|
17
|
+
from ..exceptions import StreamTimeoutError
|
|
18
|
+
from ..models.events import (
|
|
19
|
+
ErrorEvent,
|
|
20
|
+
TERMINAL_STATUSES,
|
|
21
|
+
ServerEvent,
|
|
22
|
+
StatusEvent,
|
|
23
|
+
parse_server_event,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _log_event(event: ServerEvent) -> None:
|
|
28
|
+
"""Log an SSE event at the appropriate level."""
|
|
29
|
+
if isinstance(event, StatusEvent):
|
|
30
|
+
msg = f"[{event.status}]"
|
|
31
|
+
if event.progress and event.progress.overall_percent:
|
|
32
|
+
msg += f" {event.progress.overall_percent}"
|
|
33
|
+
if event.progress.overall_waypoints:
|
|
34
|
+
msg += f" (waypoints: {event.progress.overall_waypoints})"
|
|
35
|
+
if event.status in TERMINAL_STATUSES:
|
|
36
|
+
logger.info("%s %s", event.resource_id, msg)
|
|
37
|
+
else:
|
|
38
|
+
logger.info("%s %s", event.resource_id, msg)
|
|
39
|
+
elif isinstance(event, ErrorEvent):
|
|
40
|
+
logger.warning("SSE error: %s", event.message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from .._http import AsyncHTTPClient, HTTPClient
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EventStream:
|
|
48
|
+
"""Synchronous iterator over typed SSE events.
|
|
49
|
+
|
|
50
|
+
Wraps an SSEConnection and parses raw events into typed
|
|
51
|
+
ServerEvent models. Supports context manager protocol.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
with stream_ops.stream(resource_id) as events:
|
|
55
|
+
for event in events:
|
|
56
|
+
if event.event_type == "status":
|
|
57
|
+
print(event.status)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, connection: SSEConnection) -> None:
|
|
61
|
+
self._connection = connection
|
|
62
|
+
|
|
63
|
+
def __iter__(self) -> Iterator[ServerEvent]:
|
|
64
|
+
for raw_event in self._connection:
|
|
65
|
+
try:
|
|
66
|
+
event = parse_server_event(raw_event.event, raw_event.data)
|
|
67
|
+
except ValueError:
|
|
68
|
+
# Skip unparseable events rather than crashing
|
|
69
|
+
continue
|
|
70
|
+
_log_event(event)
|
|
71
|
+
yield event
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""Close the underlying SSE connection."""
|
|
75
|
+
self._connection.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> "EventStream":
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *args: object) -> None:
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AsyncEventStream:
|
|
85
|
+
"""Asynchronous iterator over typed SSE events.
|
|
86
|
+
|
|
87
|
+
Async counterpart of EventStream.
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
async with stream_ops.stream(resource_id) as events:
|
|
91
|
+
async for event in events:
|
|
92
|
+
...
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, connection: AsyncSSEConnection) -> None:
|
|
96
|
+
self._connection = connection
|
|
97
|
+
|
|
98
|
+
async def __aiter__(self) -> AsyncIterator[ServerEvent]:
|
|
99
|
+
async for raw_event in self._connection:
|
|
100
|
+
try:
|
|
101
|
+
event = parse_server_event(raw_event.event, raw_event.data)
|
|
102
|
+
except ValueError:
|
|
103
|
+
continue
|
|
104
|
+
_log_event(event)
|
|
105
|
+
yield event
|
|
106
|
+
|
|
107
|
+
async def close(self) -> None:
|
|
108
|
+
"""Close the underlying SSE connection."""
|
|
109
|
+
await self._connection.close()
|
|
110
|
+
|
|
111
|
+
async def __aenter__(self) -> "AsyncEventStream":
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
async def __aexit__(self, *args: object) -> None:
|
|
115
|
+
await self.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class StreamOperations:
|
|
119
|
+
"""Synchronous streaming helpers, composed into resource classes.
|
|
120
|
+
|
|
121
|
+
Follows the same pattern as FileOperations and UploadOperations.
|
|
122
|
+
Each resource class instantiates this with its SSE path template.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
http: "HTTPClient",
|
|
128
|
+
sse_path_template: str,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Initialize stream operations.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
http: HTTP client instance.
|
|
134
|
+
sse_path_template: Path template with {resource_id}
|
|
135
|
+
placeholder (e.g. "/v0/events/environments/{resource_id}").
|
|
136
|
+
"""
|
|
137
|
+
self._http = http
|
|
138
|
+
self._sse_path_template = sse_path_template
|
|
139
|
+
|
|
140
|
+
def stream(
|
|
141
|
+
self,
|
|
142
|
+
resource_id: str,
|
|
143
|
+
*,
|
|
144
|
+
max_total_seconds: float = 7200.0,
|
|
145
|
+
) -> EventStream:
|
|
146
|
+
"""Open a typed SSE event stream for a resource.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
resource_id: The resource identifier.
|
|
150
|
+
max_total_seconds: Maximum total stream duration
|
|
151
|
+
including reconnections. Defaults to 2 hours.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
An EventStream (iterator + context manager).
|
|
155
|
+
"""
|
|
156
|
+
from .._http import _build_headers
|
|
157
|
+
|
|
158
|
+
path = self._sse_path_template.format(resource_id=resource_id)
|
|
159
|
+
connection = SSEConnection(
|
|
160
|
+
client=self._http._get_client(),
|
|
161
|
+
url=path,
|
|
162
|
+
headers=_build_headers(self._http.config),
|
|
163
|
+
max_total_seconds=max_total_seconds,
|
|
164
|
+
)
|
|
165
|
+
return EventStream(connection)
|
|
166
|
+
|
|
167
|
+
def wait(
|
|
168
|
+
self,
|
|
169
|
+
resource_id: str,
|
|
170
|
+
*,
|
|
171
|
+
timeout: float = 3600.0,
|
|
172
|
+
on_event: Optional[Callable[[ServerEvent], None]] = None,
|
|
173
|
+
terminal_statuses: FrozenSet[str] = TERMINAL_STATUSES,
|
|
174
|
+
) -> StatusEvent:
|
|
175
|
+
"""Block until a terminal status is received via SSE.
|
|
176
|
+
|
|
177
|
+
Consumes the event stream internally, calling on_event
|
|
178
|
+
for each event if provided. Returns the final StatusEvent.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
resource_id: The resource identifier.
|
|
182
|
+
timeout: Maximum wait time in seconds. Defaults to 1 hour.
|
|
183
|
+
on_event: Optional callback invoked for each event.
|
|
184
|
+
terminal_statuses: Set of statuses that end the wait.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
The final StatusEvent with terminal status.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
StreamTimeoutError: If timeout is exceeded.
|
|
191
|
+
"""
|
|
192
|
+
start_time = time.monotonic()
|
|
193
|
+
last_status_event: Optional[StatusEvent] = None
|
|
194
|
+
|
|
195
|
+
with self.stream(
|
|
196
|
+
resource_id,
|
|
197
|
+
max_total_seconds=timeout,
|
|
198
|
+
) as events:
|
|
199
|
+
for event in events:
|
|
200
|
+
elapsed = time.monotonic() - start_time
|
|
201
|
+
if elapsed >= timeout:
|
|
202
|
+
raise StreamTimeoutError(
|
|
203
|
+
f"Timed out after {timeout}s waiting "
|
|
204
|
+
f"for resource {resource_id}"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if on_event is not None:
|
|
208
|
+
on_event(event)
|
|
209
|
+
|
|
210
|
+
if isinstance(event, StatusEvent):
|
|
211
|
+
last_status_event = event
|
|
212
|
+
if event.status in terminal_statuses:
|
|
213
|
+
return event
|
|
214
|
+
|
|
215
|
+
# Stream ended without terminal status
|
|
216
|
+
if last_status_event is not None:
|
|
217
|
+
return last_status_event
|
|
218
|
+
|
|
219
|
+
raise StreamTimeoutError(
|
|
220
|
+
f"SSE stream ended without terminal status "
|
|
221
|
+
f"for resource {resource_id}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class AsyncStreamOperations:
|
|
226
|
+
"""Asynchronous streaming helpers, composed into resource classes.
|
|
227
|
+
|
|
228
|
+
Async counterpart of StreamOperations.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
http: "AsyncHTTPClient",
|
|
234
|
+
sse_path_template: str,
|
|
235
|
+
) -> None:
|
|
236
|
+
self._http = http
|
|
237
|
+
self._sse_path_template = sse_path_template
|
|
238
|
+
|
|
239
|
+
def stream(
|
|
240
|
+
self,
|
|
241
|
+
resource_id: str,
|
|
242
|
+
*,
|
|
243
|
+
max_total_seconds: float = 7200.0,
|
|
244
|
+
) -> AsyncEventStream:
|
|
245
|
+
"""Open a typed async SSE event stream for a resource.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
resource_id: The resource identifier.
|
|
249
|
+
max_total_seconds: Maximum total stream duration.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
An AsyncEventStream (async iterator + context manager).
|
|
253
|
+
"""
|
|
254
|
+
from .._http import _build_headers
|
|
255
|
+
|
|
256
|
+
path = self._sse_path_template.format(resource_id=resource_id)
|
|
257
|
+
connection = AsyncSSEConnection(
|
|
258
|
+
client=self._http._get_client(),
|
|
259
|
+
url=path,
|
|
260
|
+
headers=_build_headers(self._http.config),
|
|
261
|
+
max_total_seconds=max_total_seconds,
|
|
262
|
+
)
|
|
263
|
+
return AsyncEventStream(connection)
|
|
264
|
+
|
|
265
|
+
async def wait(
|
|
266
|
+
self,
|
|
267
|
+
resource_id: str,
|
|
268
|
+
*,
|
|
269
|
+
timeout: float = 3600.0,
|
|
270
|
+
on_event: Optional[Callable[[ServerEvent], None]] = None,
|
|
271
|
+
terminal_statuses: FrozenSet[str] = TERMINAL_STATUSES,
|
|
272
|
+
) -> StatusEvent:
|
|
273
|
+
"""Block until a terminal status is received via SSE.
|
|
274
|
+
|
|
275
|
+
Async counterpart of StreamOperations.wait().
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
resource_id: The resource identifier.
|
|
279
|
+
timeout: Maximum wait time in seconds.
|
|
280
|
+
on_event: Optional callback invoked for each event.
|
|
281
|
+
terminal_statuses: Set of statuses that end the wait.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The final StatusEvent with terminal status.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
StreamTimeoutError: If timeout is exceeded.
|
|
288
|
+
"""
|
|
289
|
+
start_time = time.monotonic()
|
|
290
|
+
last_status_event: Optional[StatusEvent] = None
|
|
291
|
+
|
|
292
|
+
async with self.stream(
|
|
293
|
+
resource_id,
|
|
294
|
+
max_total_seconds=timeout,
|
|
295
|
+
) as events:
|
|
296
|
+
async for event in events:
|
|
297
|
+
elapsed = time.monotonic() - start_time
|
|
298
|
+
if elapsed >= timeout:
|
|
299
|
+
raise StreamTimeoutError(
|
|
300
|
+
f"Timed out after {timeout}s waiting "
|
|
301
|
+
f"for resource {resource_id}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if on_event is not None:
|
|
305
|
+
on_event(event)
|
|
306
|
+
|
|
307
|
+
if isinstance(event, StatusEvent):
|
|
308
|
+
last_status_event = event
|
|
309
|
+
if event.status in terminal_statuses:
|
|
310
|
+
return event
|
|
311
|
+
|
|
312
|
+
if last_status_event is not None:
|
|
313
|
+
return last_status_event
|
|
314
|
+
|
|
315
|
+
raise StreamTimeoutError(
|
|
316
|
+
f"SSE stream ended without terminal status "
|
|
317
|
+
f"for resource {resource_id}"
|
|
318
|
+
)
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Reusable upload operation mixins for resources."""
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..exceptions import FileUploadError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .._http import AsyncHTTPClient, HTTPClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UploadOperations:
|
|
17
|
+
"""Sync upload operations for a resource."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, http: "HTTPClient", base_path: str) -> None:
|
|
20
|
+
self._http = http
|
|
21
|
+
self._base_path = base_path
|
|
22
|
+
|
|
23
|
+
def initiate_upload(
|
|
24
|
+
self,
|
|
25
|
+
resource_id: str,
|
|
26
|
+
filename: str,
|
|
27
|
+
content_type: Optional[str] = None,
|
|
28
|
+
response_model: type = None, # type: ignore[assignment]
|
|
29
|
+
) -> Any:
|
|
30
|
+
"""Initiate a file upload session.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
resource_id: The resource identifier.
|
|
34
|
+
filename: Name of the file to upload.
|
|
35
|
+
content_type: Optional MIME type override.
|
|
36
|
+
response_model: Pydantic model for response.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Upload session response with signed URL.
|
|
40
|
+
"""
|
|
41
|
+
body: Dict[str, Any] = {"filename": filename}
|
|
42
|
+
if content_type:
|
|
43
|
+
body["content_type"] = content_type
|
|
44
|
+
return self._http.request(
|
|
45
|
+
"POST",
|
|
46
|
+
f"{self._base_path}/{resource_id}/uploads",
|
|
47
|
+
json_data=body,
|
|
48
|
+
response_model=response_model,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def complete_upload(
|
|
52
|
+
self,
|
|
53
|
+
resource_id: str,
|
|
54
|
+
upload_id: str,
|
|
55
|
+
purpose: Optional[str] = None,
|
|
56
|
+
response_model: type = None, # type: ignore[assignment]
|
|
57
|
+
) -> Any:
|
|
58
|
+
"""Mark an upload session as complete.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
resource_id: The resource identifier.
|
|
62
|
+
upload_id: The upload session identifier.
|
|
63
|
+
purpose: Optional purpose tag for the file.
|
|
64
|
+
response_model: Pydantic model for response.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Completed upload response.
|
|
68
|
+
"""
|
|
69
|
+
params = {}
|
|
70
|
+
if purpose:
|
|
71
|
+
params["purpose"] = purpose
|
|
72
|
+
return self._http.request(
|
|
73
|
+
"POST",
|
|
74
|
+
(
|
|
75
|
+
f"{self._base_path}/{resource_id}"
|
|
76
|
+
f"/uploads/{upload_id}/complete"
|
|
77
|
+
),
|
|
78
|
+
params=params if params else None,
|
|
79
|
+
response_model=response_model,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def upload(
|
|
83
|
+
self,
|
|
84
|
+
resource_id: str,
|
|
85
|
+
file_path: Union[str, Path],
|
|
86
|
+
content_type: Optional[str] = None,
|
|
87
|
+
purpose: Optional[str] = None,
|
|
88
|
+
initiate_model: type = None, # type: ignore[assignment]
|
|
89
|
+
complete_model: type = None, # type: ignore[assignment]
|
|
90
|
+
) -> Any:
|
|
91
|
+
"""Upload a file end-to-end.
|
|
92
|
+
|
|
93
|
+
Initiates an upload session, transfers the file
|
|
94
|
+
to the signed URL, then completes the session.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
resource_id: The resource identifier.
|
|
98
|
+
file_path: Local path to the file.
|
|
99
|
+
content_type: Optional MIME type override.
|
|
100
|
+
purpose: Optional purpose tag for the file.
|
|
101
|
+
initiate_model: Model for initiate response.
|
|
102
|
+
complete_model: Model for complete response.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Completed upload response.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
FileNotFoundError: If file does not exist.
|
|
109
|
+
FileUploadError: If upload fails.
|
|
110
|
+
"""
|
|
111
|
+
file_path = Path(file_path)
|
|
112
|
+
if not file_path.exists():
|
|
113
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
114
|
+
filename = file_path.name
|
|
115
|
+
if content_type is None:
|
|
116
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
117
|
+
if content_type is None:
|
|
118
|
+
content_type = "application/octet-stream"
|
|
119
|
+
|
|
120
|
+
upload_resp = self.initiate_upload(
|
|
121
|
+
resource_id=resource_id,
|
|
122
|
+
filename=filename,
|
|
123
|
+
content_type=content_type,
|
|
124
|
+
response_model=initiate_model,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with (
|
|
129
|
+
open(file_path, "rb") as f,
|
|
130
|
+
httpx.Client() as client,
|
|
131
|
+
):
|
|
132
|
+
r = client.request(
|
|
133
|
+
method=upload_resp.method,
|
|
134
|
+
url=upload_resp.upload_url,
|
|
135
|
+
content=f,
|
|
136
|
+
headers={"Content-Type": content_type},
|
|
137
|
+
)
|
|
138
|
+
r.raise_for_status()
|
|
139
|
+
|
|
140
|
+
return self.complete_upload(
|
|
141
|
+
resource_id=resource_id,
|
|
142
|
+
upload_id=upload_resp.upload_id,
|
|
143
|
+
purpose=purpose,
|
|
144
|
+
response_model=complete_model,
|
|
145
|
+
)
|
|
146
|
+
except FileUploadError:
|
|
147
|
+
raise
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise FileUploadError(f"Failed to upload file: {e}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AsyncUploadOperations:
|
|
153
|
+
"""Async upload operations for a resource."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, http: "AsyncHTTPClient", base_path: str) -> None:
|
|
156
|
+
self._http = http
|
|
157
|
+
self._base_path = base_path
|
|
158
|
+
|
|
159
|
+
async def initiate_upload(
|
|
160
|
+
self,
|
|
161
|
+
resource_id: str,
|
|
162
|
+
filename: str,
|
|
163
|
+
content_type: Optional[str] = None,
|
|
164
|
+
response_model: type = None, # type: ignore[assignment]
|
|
165
|
+
) -> Any:
|
|
166
|
+
"""Initiate a file upload session.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
resource_id: The resource identifier.
|
|
170
|
+
filename: Name of the file to upload.
|
|
171
|
+
content_type: Optional MIME type override.
|
|
172
|
+
response_model: Pydantic model for response.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Upload session response with signed URL.
|
|
176
|
+
"""
|
|
177
|
+
body: Dict[str, Any] = {"filename": filename}
|
|
178
|
+
if content_type:
|
|
179
|
+
body["content_type"] = content_type
|
|
180
|
+
return await self._http.request(
|
|
181
|
+
"POST",
|
|
182
|
+
f"{self._base_path}/{resource_id}/uploads",
|
|
183
|
+
json_data=body,
|
|
184
|
+
response_model=response_model,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def complete_upload(
|
|
188
|
+
self,
|
|
189
|
+
resource_id: str,
|
|
190
|
+
upload_id: str,
|
|
191
|
+
purpose: Optional[str] = None,
|
|
192
|
+
response_model: type = None, # type: ignore[assignment]
|
|
193
|
+
) -> Any:
|
|
194
|
+
"""Mark an upload session as complete.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
resource_id: The resource identifier.
|
|
198
|
+
upload_id: The upload session identifier.
|
|
199
|
+
purpose: Optional purpose tag for the file.
|
|
200
|
+
response_model: Pydantic model for response.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Completed upload response.
|
|
204
|
+
"""
|
|
205
|
+
params = {}
|
|
206
|
+
if purpose:
|
|
207
|
+
params["purpose"] = purpose
|
|
208
|
+
return await self._http.request(
|
|
209
|
+
"POST",
|
|
210
|
+
(
|
|
211
|
+
f"{self._base_path}/{resource_id}"
|
|
212
|
+
f"/uploads/{upload_id}/complete"
|
|
213
|
+
),
|
|
214
|
+
params=params if params else None,
|
|
215
|
+
response_model=response_model,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
async def upload(
|
|
219
|
+
self,
|
|
220
|
+
resource_id: str,
|
|
221
|
+
file_path: Union[str, Path],
|
|
222
|
+
content_type: Optional[str] = None,
|
|
223
|
+
purpose: Optional[str] = None,
|
|
224
|
+
initiate_model: type = None, # type: ignore[assignment]
|
|
225
|
+
complete_model: type = None, # type: ignore[assignment]
|
|
226
|
+
) -> Any:
|
|
227
|
+
"""Upload a file end-to-end.
|
|
228
|
+
|
|
229
|
+
Initiates an upload session, transfers the file
|
|
230
|
+
to the signed URL, then completes the session.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
resource_id: The resource identifier.
|
|
234
|
+
file_path: Local path to the file.
|
|
235
|
+
content_type: Optional MIME type override.
|
|
236
|
+
purpose: Optional purpose tag for the file.
|
|
237
|
+
initiate_model: Model for initiate response.
|
|
238
|
+
complete_model: Model for complete response.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Completed upload response.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
FileNotFoundError: If file does not exist.
|
|
245
|
+
FileUploadError: If upload fails.
|
|
246
|
+
"""
|
|
247
|
+
file_path = Path(file_path)
|
|
248
|
+
if not file_path.exists():
|
|
249
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
250
|
+
filename = file_path.name
|
|
251
|
+
if content_type is None:
|
|
252
|
+
content_type, _ = mimetypes.guess_type(str(file_path))
|
|
253
|
+
if content_type is None:
|
|
254
|
+
content_type = "application/octet-stream"
|
|
255
|
+
|
|
256
|
+
upload_resp = await self.initiate_upload(
|
|
257
|
+
resource_id=resource_id,
|
|
258
|
+
filename=filename,
|
|
259
|
+
content_type=content_type,
|
|
260
|
+
response_model=initiate_model,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
async with (
|
|
265
|
+
await anyio.open_file(file_path, "rb") as f,
|
|
266
|
+
httpx.AsyncClient() as client,
|
|
267
|
+
):
|
|
268
|
+
file_content = await f.read()
|
|
269
|
+
r = await client.request(
|
|
270
|
+
method=upload_resp.method,
|
|
271
|
+
url=upload_resp.upload_url,
|
|
272
|
+
content=file_content,
|
|
273
|
+
headers={"Content-Type": content_type},
|
|
274
|
+
)
|
|
275
|
+
r.raise_for_status()
|
|
276
|
+
|
|
277
|
+
return await self.complete_upload(
|
|
278
|
+
resource_id=resource_id,
|
|
279
|
+
upload_id=upload_resp.upload_id,
|
|
280
|
+
purpose=purpose,
|
|
281
|
+
response_model=complete_model,
|
|
282
|
+
)
|
|
283
|
+
except FileUploadError:
|
|
284
|
+
raise
|
|
285
|
+
except Exception as e:
|
|
286
|
+
raise FileUploadError(f"Failed to upload file: {e}")
|