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.
@@ -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}")