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/_sse.py ADDED
@@ -0,0 +1,497 @@
1
+ """SSE (Server-Sent Events) transport layer for the Waveium SDK.
2
+
3
+ Implements the W3C SSE specification with auto-reconnection,
4
+ keepalive handling, and configurable backoff. Used internally
5
+ by StreamOperations to provide typed event streams.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import (
12
+ AsyncIterator,
13
+ Dict,
14
+ Iterator,
15
+ List,
16
+ Optional,
17
+ )
18
+
19
+ import httpx
20
+
21
+ logger = logging.getLogger("waveium")
22
+
23
+ from .exceptions import (
24
+ AuthenticationError,
25
+ AuthorizationError,
26
+ NotFoundError,
27
+ StreamReconnectError,
28
+ StreamTimeoutError,
29
+ ValidationError,
30
+ )
31
+ from ._http import _handle_error_response
32
+
33
+ # SSE-specific timeout: long read (server keepalive every 15s,
34
+ # deadline 330s), normal connect/write/pool.
35
+ SSE_TIMEOUT = httpx.Timeout(
36
+ connect=10.0,
37
+ read=360.0,
38
+ write=10.0,
39
+ pool=10.0,
40
+ )
41
+
42
+ # HTTP status codes that should not be retried
43
+ _NON_RETRYABLE_STATUSES = {400, 401, 403, 404}
44
+
45
+
46
+ @dataclass
47
+ class RawSSEEvent:
48
+ """A single parsed SSE event from the wire."""
49
+
50
+ event: str = "message"
51
+ data: str = ""
52
+ id: Optional[str] = None
53
+ retry: Optional[int] = None
54
+
55
+
56
+ def _parse_sse_lines(lines: Iterator[str]) -> Iterator[RawSSEEvent]:
57
+ """Parse SSE lines per the W3C EventSource specification.
58
+
59
+ Handles event, data, id, retry fields, multi-line data
60
+ concatenation, and comment (keepalive) filtering.
61
+
62
+ Args:
63
+ lines: Iterator of text lines from the SSE stream.
64
+
65
+ Yields:
66
+ Parsed RawSSEEvent instances.
67
+ """
68
+ current_event = "message"
69
+ current_data: List[str] = []
70
+ current_id: Optional[str] = None
71
+ current_retry: Optional[int] = None
72
+
73
+ for raw_line in lines:
74
+ line = raw_line.rstrip("\n").rstrip("\r")
75
+
76
+ # Empty line: dispatch event if data is non-empty
77
+ if not line:
78
+ if current_data:
79
+ yield RawSSEEvent(
80
+ event=current_event,
81
+ data="\n".join(current_data),
82
+ id=current_id,
83
+ retry=current_retry,
84
+ )
85
+ # Reset for next event
86
+ current_event = "message"
87
+ current_data = []
88
+ current_id = None
89
+ current_retry = None
90
+ continue
91
+
92
+ # Comment line (keepalive or debug)
93
+ if line.startswith(":"):
94
+ continue
95
+
96
+ # Field parsing
97
+ if ":" in line:
98
+ field_name, _, value = line.partition(":")
99
+ # Strip single leading space from value per spec
100
+ if value.startswith(" "):
101
+ value = value[1:]
102
+ else:
103
+ field_name = line
104
+ value = ""
105
+
106
+ if field_name == "event":
107
+ current_event = value
108
+ elif field_name == "data":
109
+ current_data.append(value)
110
+ elif field_name == "id":
111
+ current_id = value
112
+ elif field_name == "retry":
113
+ try:
114
+ current_retry = int(value)
115
+ except ValueError:
116
+ pass
117
+
118
+
119
+ async def _async_parse_sse_lines(
120
+ lines: AsyncIterator[str],
121
+ ) -> AsyncIterator[RawSSEEvent]:
122
+ """Async version of _parse_sse_lines.
123
+
124
+ Args:
125
+ lines: Async iterator of text lines from the SSE stream.
126
+
127
+ Yields:
128
+ Parsed RawSSEEvent instances.
129
+ """
130
+ current_event = "message"
131
+ current_data: List[str] = []
132
+ current_id: Optional[str] = None
133
+ current_retry: Optional[int] = None
134
+
135
+ async for raw_line in lines:
136
+ line = raw_line.rstrip("\n").rstrip("\r")
137
+
138
+ if not line:
139
+ if current_data:
140
+ yield RawSSEEvent(
141
+ event=current_event,
142
+ data="\n".join(current_data),
143
+ id=current_id,
144
+ retry=current_retry,
145
+ )
146
+ current_event = "message"
147
+ current_data = []
148
+ current_id = None
149
+ current_retry = None
150
+ continue
151
+
152
+ if line.startswith(":"):
153
+ continue
154
+
155
+ if ":" in line:
156
+ field_name, _, value = line.partition(":")
157
+ if value.startswith(" "):
158
+ value = value[1:]
159
+ else:
160
+ field_name = line
161
+ value = ""
162
+
163
+ if field_name == "event":
164
+ current_event = value
165
+ elif field_name == "data":
166
+ current_data.append(value)
167
+ elif field_name == "id":
168
+ current_id = value
169
+ elif field_name == "retry":
170
+ try:
171
+ current_retry = int(value)
172
+ except ValueError:
173
+ pass
174
+
175
+
176
+ @dataclass
177
+ class SSEConnection:
178
+ """Synchronous SSE connection with auto-reconnection.
179
+
180
+ Handles the server's 5-minute connection timeout by
181
+ automatically reconnecting when a timeout event is received.
182
+ Network errors trigger exponential backoff.
183
+
184
+ Usage:
185
+ conn = SSEConnection(client, url, headers=headers)
186
+ with conn:
187
+ for event in conn:
188
+ process(event)
189
+ """
190
+
191
+ client: httpx.Client
192
+ url: str
193
+ headers: Dict[str, str] = field(default_factory=dict)
194
+ max_reconnects: int = 50
195
+ max_total_seconds: float = 7200.0
196
+ initial_retry_ms: int = 1000
197
+ max_retry_ms: int = 30000
198
+
199
+ _last_event_id: Optional[str] = field(default=None, init=False, repr=False)
200
+ _server_retry_ms: Optional[int] = field(
201
+ default=None, init=False, repr=False
202
+ )
203
+ _closed: bool = field(default=False, init=False, repr=False)
204
+
205
+ def __iter__(self) -> Iterator[RawSSEEvent]:
206
+ start_time = time.monotonic()
207
+ reconnect_count = 0
208
+ backoff_ms = self.initial_retry_ms
209
+ logger.debug("SSE connecting to %s", self.url)
210
+
211
+ while not self._closed:
212
+ # Check total time limit
213
+ elapsed = time.monotonic() - start_time
214
+ if elapsed >= self.max_total_seconds:
215
+ raise StreamTimeoutError(
216
+ f"SSE stream exceeded maximum duration "
217
+ f"of {self.max_total_seconds}s"
218
+ )
219
+
220
+ # Check reconnect limit
221
+ if reconnect_count > self.max_reconnects:
222
+ raise StreamReconnectError(
223
+ f"SSE stream exceeded maximum reconnect "
224
+ f"count of {self.max_reconnects}"
225
+ )
226
+
227
+ # Build request headers
228
+ request_headers = {
229
+ **self.headers,
230
+ "Accept": "text/event-stream",
231
+ "Cache-Control": "no-cache",
232
+ }
233
+ if self._last_event_id is not None:
234
+ request_headers["Last-Event-ID"] = self._last_event_id
235
+
236
+ try:
237
+ with self.client.stream(
238
+ "GET",
239
+ self.url,
240
+ headers=request_headers,
241
+ timeout=SSE_TIMEOUT,
242
+ ) as response:
243
+ # Handle non-success status codes
244
+ if response.status_code >= 400:
245
+ if response.status_code in _NON_RETRYABLE_STATUSES:
246
+ # Read body for error details
247
+ response.read()
248
+ raise _handle_error_response(response)
249
+ # 5xx: will retry after backoff
250
+ logger.warning(
251
+ "SSE server error %d, retrying in %dms",
252
+ response.status_code,
253
+ backoff_ms,
254
+ )
255
+ reconnect_count += 1
256
+ self._backoff_sleep(backoff_ms)
257
+ backoff_ms = min(backoff_ms * 2, self.max_retry_ms)
258
+ continue
259
+
260
+ # Reset backoff on successful connection
261
+ backoff_ms = self.initial_retry_ms
262
+ is_timeout_reconnect = False
263
+ logger.debug("SSE connected to %s", self.url)
264
+
265
+ for event in _parse_sse_lines(response.iter_lines()):
266
+ if self._closed:
267
+ return
268
+
269
+ # Track last event ID for reconnection
270
+ if event.id is not None:
271
+ self._last_event_id = event.id
272
+
273
+ # Track server-suggested retry interval
274
+ if event.retry is not None:
275
+ self._server_retry_ms = event.retry
276
+
277
+ # Timeout event: reconnect immediately
278
+ if event.event == "timeout":
279
+ logger.debug("SSE server timeout, reconnecting")
280
+ is_timeout_reconnect = True
281
+ reconnect_count += 1
282
+ break
283
+
284
+ yield event
285
+
286
+ # If we exited the loop normally (stream ended)
287
+ # without a timeout event, reconnect with backoff
288
+ if not is_timeout_reconnect:
289
+ if self._closed:
290
+ return
291
+ reconnect_count += 1
292
+ retry_ms = (
293
+ self._server_retry_ms
294
+ if self._server_retry_ms is not None
295
+ else backoff_ms
296
+ )
297
+ logger.debug(
298
+ "SSE stream ended, reconnecting in %dms",
299
+ retry_ms,
300
+ )
301
+ self._backoff_sleep(retry_ms)
302
+ backoff_ms = min(backoff_ms * 2, self.max_retry_ms)
303
+
304
+ except (
305
+ httpx.RequestError,
306
+ httpx.StreamError,
307
+ ) as e:
308
+ if self._closed:
309
+ return
310
+ reconnect_count += 1
311
+ if reconnect_count > self.max_reconnects:
312
+ raise StreamReconnectError(
313
+ f"SSE stream exceeded maximum reconnect "
314
+ f"count of {self.max_reconnects}"
315
+ ) from e
316
+ retry_ms = (
317
+ self._server_retry_ms
318
+ if self._server_retry_ms is not None
319
+ else backoff_ms
320
+ )
321
+ logger.warning(
322
+ "SSE connection error: %s, retrying in %dms",
323
+ e,
324
+ retry_ms,
325
+ )
326
+ self._backoff_sleep(retry_ms)
327
+ backoff_ms = min(backoff_ms * 2, self.max_retry_ms)
328
+
329
+ except (
330
+ AuthenticationError,
331
+ AuthorizationError,
332
+ NotFoundError,
333
+ ValidationError,
334
+ ):
335
+ # Non-retryable errors: propagate immediately
336
+ raise
337
+
338
+ def _backoff_sleep(self, ms: int) -> None:
339
+ """Sleep for backoff duration, respecting close signal."""
340
+ time.sleep(ms / 1000.0)
341
+
342
+ def close(self) -> None:
343
+ """Signal the connection to stop iterating."""
344
+ self._closed = True
345
+
346
+ def __enter__(self) -> "SSEConnection":
347
+ return self
348
+
349
+ def __exit__(self, *args: object) -> None:
350
+ self.close()
351
+
352
+
353
+ @dataclass
354
+ class AsyncSSEConnection:
355
+ """Asynchronous SSE connection with auto-reconnection.
356
+
357
+ Async counterpart of SSEConnection. Handles the server's
358
+ 5-minute connection timeout and network errors with
359
+ automatic reconnection.
360
+
361
+ Usage:
362
+ conn = AsyncSSEConnection(client, url, headers=headers)
363
+ async with conn:
364
+ async for event in conn:
365
+ await process(event)
366
+ """
367
+
368
+ client: httpx.AsyncClient
369
+ url: str
370
+ headers: Dict[str, str] = field(default_factory=dict)
371
+ max_reconnects: int = 50
372
+ max_total_seconds: float = 7200.0
373
+ initial_retry_ms: int = 1000
374
+ max_retry_ms: int = 30000
375
+
376
+ _last_event_id: Optional[str] = field(default=None, init=False, repr=False)
377
+ _server_retry_ms: Optional[int] = field(
378
+ default=None, init=False, repr=False
379
+ )
380
+ _closed: bool = field(default=False, init=False, repr=False)
381
+
382
+ async def __aiter__(self) -> AsyncIterator[RawSSEEvent]:
383
+ import asyncio
384
+
385
+ start_time = time.monotonic()
386
+ reconnect_count = 0
387
+ backoff_ms = self.initial_retry_ms
388
+
389
+ while not self._closed:
390
+ elapsed = time.monotonic() - start_time
391
+ if elapsed >= self.max_total_seconds:
392
+ raise StreamTimeoutError(
393
+ f"SSE stream exceeded maximum duration "
394
+ f"of {self.max_total_seconds}s"
395
+ )
396
+
397
+ if reconnect_count > self.max_reconnects:
398
+ raise StreamReconnectError(
399
+ f"SSE stream exceeded maximum reconnect "
400
+ f"count of {self.max_reconnects}"
401
+ )
402
+
403
+ request_headers = {
404
+ **self.headers,
405
+ "Accept": "text/event-stream",
406
+ "Cache-Control": "no-cache",
407
+ }
408
+ if self._last_event_id is not None:
409
+ request_headers["Last-Event-ID"] = self._last_event_id
410
+
411
+ try:
412
+ async with self.client.stream(
413
+ "GET",
414
+ self.url,
415
+ headers=request_headers,
416
+ timeout=SSE_TIMEOUT,
417
+ ) as response:
418
+ if response.status_code >= 400:
419
+ if response.status_code in _NON_RETRYABLE_STATUSES:
420
+ await response.aread()
421
+ raise _handle_error_response(response)
422
+ reconnect_count += 1
423
+ await asyncio.sleep(backoff_ms / 1000.0)
424
+ backoff_ms = min(backoff_ms * 2, self.max_retry_ms)
425
+ continue
426
+
427
+ backoff_ms = self.initial_retry_ms
428
+ is_timeout_reconnect = False
429
+
430
+ async for event in _async_parse_sse_lines(
431
+ response.aiter_lines()
432
+ ):
433
+ if self._closed:
434
+ return
435
+
436
+ if event.id is not None:
437
+ self._last_event_id = event.id
438
+
439
+ if event.retry is not None:
440
+ self._server_retry_ms = event.retry
441
+
442
+ if event.event == "timeout":
443
+ is_timeout_reconnect = True
444
+ reconnect_count += 1
445
+ break
446
+
447
+ yield event
448
+
449
+ if not is_timeout_reconnect:
450
+ if self._closed:
451
+ return
452
+ reconnect_count += 1
453
+ retry_ms = (
454
+ self._server_retry_ms
455
+ if self._server_retry_ms is not None
456
+ else backoff_ms
457
+ )
458
+ await asyncio.sleep(retry_ms / 1000.0)
459
+ backoff_ms = min(backoff_ms * 2, self.max_retry_ms)
460
+
461
+ except (
462
+ httpx.RequestError,
463
+ httpx.StreamError,
464
+ ) as e:
465
+ if self._closed:
466
+ return
467
+ reconnect_count += 1
468
+ if reconnect_count > self.max_reconnects:
469
+ raise StreamReconnectError(
470
+ f"SSE stream exceeded maximum reconnect "
471
+ f"count of {self.max_reconnects}"
472
+ ) from e
473
+ retry_ms = (
474
+ self._server_retry_ms
475
+ if self._server_retry_ms is not None
476
+ else backoff_ms
477
+ )
478
+ await asyncio.sleep(retry_ms / 1000.0)
479
+ backoff_ms = min(backoff_ms * 2, self.max_retry_ms)
480
+
481
+ except (
482
+ AuthenticationError,
483
+ AuthorizationError,
484
+ NotFoundError,
485
+ ValidationError,
486
+ ):
487
+ raise
488
+
489
+ async def close(self) -> None:
490
+ """Signal the connection to stop iterating."""
491
+ self._closed = True
492
+
493
+ async def __aenter__(self) -> "AsyncSSEConnection":
494
+ return self
495
+
496
+ async def __aexit__(self, *args: object) -> None:
497
+ await self.close()
@@ -0,0 +1,92 @@
1
+ """Asynchronous Waveium API client."""
2
+
3
+ from typing import Optional
4
+
5
+ from ._config import Config
6
+ from ._http import AsyncHTTPClient
7
+ from .resources import (
8
+ AsyncAuthResource,
9
+ AsyncEnvironmentsResource,
10
+ AsyncExecutionsResource,
11
+ AsyncOrganizationsResource,
12
+ AsyncProjectsResource,
13
+ AsyncRolesResource,
14
+ AsyncScenesResource,
15
+ AsyncTeamsResource,
16
+ AsyncUsersResource,
17
+ )
18
+
19
+
20
+ class AsyncClient:
21
+ """Asynchronous Waveium API client.
22
+
23
+ Example:
24
+ >>> import waveium
25
+ >>> async with waveium.AsyncClient(
26
+ ... api_key="wvx_..."
27
+ ... ) as client:
28
+ ... resp = await client.environments.create(
29
+ ... name="My Environment"
30
+ ... )
31
+ ... env = resp.environment
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ base_url: Optional[str] = None,
38
+ timeout: Optional[float] = None,
39
+ max_retries: Optional[int] = None,
40
+ ) -> None:
41
+ """Initialize async Waveium client.
42
+
43
+ Args:
44
+ api_key: API key for authentication. If not
45
+ provided, will try to load from
46
+ WAVEIUM_API_KEY environment variable.
47
+ base_url: Base URL for the Waveium API.
48
+ Defaults to production URL.
49
+ timeout: Request timeout in seconds.
50
+ Defaults to 60.0.
51
+ max_retries: Maximum number of retries for
52
+ failed requests. Defaults to 3.
53
+
54
+ Raises:
55
+ ConfigurationError: If configuration is invalid.
56
+ """
57
+ self._config = Config(
58
+ api_key=api_key,
59
+ base_url=base_url,
60
+ timeout=timeout,
61
+ max_retries=max_retries,
62
+ )
63
+ self._config.validate()
64
+
65
+ self._http = AsyncHTTPClient(self._config)
66
+
67
+ # Initialize resources
68
+ self.auth = AsyncAuthResource(self._http)
69
+ self.environments = AsyncEnvironmentsResource(self._http)
70
+ self.scenes = AsyncScenesResource(self._http)
71
+ self.executions = AsyncExecutionsResource(self._http)
72
+ self.users = AsyncUsersResource(self._http)
73
+ self.organizations = AsyncOrganizationsResource(self._http)
74
+ self.roles = AsyncRolesResource(self._http)
75
+ self.teams = AsyncTeamsResource(self._http)
76
+ self.projects = AsyncProjectsResource(self._http)
77
+
78
+ async def close(self) -> None:
79
+ """Close the HTTP client and release resources."""
80
+ await self._http.close()
81
+
82
+ async def __aenter__(self) -> "AsyncClient":
83
+ """Enter async context manager."""
84
+ return self
85
+
86
+ async def __aexit__(self, *args: object) -> None:
87
+ """Exit async context manager."""
88
+ await self.close()
89
+
90
+ def __repr__(self) -> str:
91
+ """Return string representation of client."""
92
+ return f"<Waveium AsyncClient base_url=" f"{self._config.base_url}>"
waveium/client.py ADDED
@@ -0,0 +1,90 @@
1
+ """Synchronous Waveium API client."""
2
+
3
+ from typing import Optional
4
+
5
+ from ._config import Config
6
+ from ._http import HTTPClient
7
+ from .resources import (
8
+ AuthResource,
9
+ EnvironmentsResource,
10
+ ExecutionsResource,
11
+ OrganizationsResource,
12
+ ProjectsResource,
13
+ RolesResource,
14
+ ScenesResource,
15
+ TeamsResource,
16
+ UsersResource,
17
+ )
18
+
19
+
20
+ class Client:
21
+ """Synchronous Waveium API client.
22
+
23
+ Example:
24
+ >>> import waveium
25
+ >>> client = waveium.Client(api_key="wvx_...")
26
+ >>> resp = client.environments.create(
27
+ ... name="My Environment"
28
+ ... )
29
+ >>> env = resp.environment
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: Optional[str] = None,
35
+ base_url: Optional[str] = None,
36
+ timeout: Optional[float] = None,
37
+ max_retries: Optional[int] = None,
38
+ ) -> None:
39
+ """Initialize Waveium client.
40
+
41
+ Args:
42
+ api_key: API key for authentication. If not
43
+ provided, will try to load from
44
+ WAVEIUM_API_KEY environment variable.
45
+ base_url: Base URL for the Waveium API.
46
+ Defaults to production URL.
47
+ timeout: Request timeout in seconds.
48
+ Defaults to 60.0.
49
+ max_retries: Maximum number of retries for
50
+ failed requests. Defaults to 3.
51
+
52
+ Raises:
53
+ ConfigurationError: If configuration is invalid.
54
+ """
55
+ self._config = Config(
56
+ api_key=api_key,
57
+ base_url=base_url,
58
+ timeout=timeout,
59
+ max_retries=max_retries,
60
+ )
61
+ self._config.validate()
62
+
63
+ self._http = HTTPClient(self._config)
64
+
65
+ # Initialize resources
66
+ self.auth = AuthResource(self._http)
67
+ self.environments = EnvironmentsResource(self._http)
68
+ self.scenes = ScenesResource(self._http)
69
+ self.executions = ExecutionsResource(self._http)
70
+ self.users = UsersResource(self._http)
71
+ self.organizations = OrganizationsResource(self._http)
72
+ self.roles = RolesResource(self._http)
73
+ self.teams = TeamsResource(self._http)
74
+ self.projects = ProjectsResource(self._http)
75
+
76
+ def close(self) -> None:
77
+ """Close the HTTP client and release resources."""
78
+ self._http.close()
79
+
80
+ def __enter__(self) -> "Client":
81
+ """Enter context manager."""
82
+ return self
83
+
84
+ def __exit__(self, *args: object) -> None:
85
+ """Exit context manager."""
86
+ self.close()
87
+
88
+ def __repr__(self) -> str:
89
+ """Return string representation of client."""
90
+ return f"<Waveium Client base_url=" f"{self._config.base_url}>"