speechweave 1.0.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,65 @@
1
+ from speechweave.version import __version__
2
+ from speechweave.async_client import AsyncSpeechWeaveClient
3
+ from speechweave.client import SpeechWeaveClient
4
+ from speechweave.errors import SpeechWeaveError
5
+ from speechweave.namespaces.assembly_compat import AssemblyTranscripts, AsyncAssemblyTranscripts
6
+ from speechweave.namespaces.deepgram_compat import AsyncDeepgramListen, DeepgramListen
7
+ from speechweave.namespaces.jobs import AsyncJobs, Jobs
8
+ from speechweave.namespaces.openai_compat import AsyncOpenAiAudio, OpenAiAudio
9
+ from speechweave.polling import async_wait_for_job, wait_for_job
10
+ from speechweave.webhooks import verify_webhook
11
+
12
+
13
+ class SpeechWeave(SpeechWeaveClient):
14
+ def __init__(
15
+ self,
16
+ *args,
17
+ **kwargs,
18
+ ):
19
+
20
+ super().__init__(
21
+ *args,
22
+ **kwargs,
23
+ )
24
+ self.audio = OpenAiAudio(self)
25
+ self.listen = DeepgramListen(self)
26
+ self.transcripts = AssemblyTranscripts(self)
27
+ self.jobs = Jobs(self)
28
+
29
+
30
+ class AsyncSpeechWeave(AsyncSpeechWeaveClient):
31
+ def __init__(
32
+ self,
33
+ *args,
34
+ **kwargs,
35
+ ):
36
+
37
+ super().__init__(
38
+ *args,
39
+ **kwargs,
40
+ )
41
+ self.audio = AsyncOpenAiAudio(self)
42
+ self.listen = AsyncDeepgramListen(self)
43
+ self.transcripts = AsyncAssemblyTranscripts(self)
44
+ self.jobs = AsyncJobs(self)
45
+
46
+
47
+ __all__ = [
48
+ "AsyncAssemblyTranscripts",
49
+ "AsyncDeepgramListen",
50
+ "AsyncJobs",
51
+ "AsyncOpenAiAudio",
52
+ "AsyncSpeechWeave",
53
+ "AsyncSpeechWeaveClient",
54
+ "AssemblyTranscripts",
55
+ "DeepgramListen",
56
+ "Jobs",
57
+ "OpenAiAudio",
58
+ "SpeechWeave",
59
+ "SpeechWeaveClient",
60
+ "SpeechWeaveError",
61
+ "__version__",
62
+ "async_wait_for_job",
63
+ "verify_webhook",
64
+ "wait_for_job",
65
+ ]
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any, BinaryIO, Mapping, MutableMapping
7
+
8
+ import httpx
9
+
10
+ from speechweave.client import UploadBody, _upload_headers
11
+ from speechweave.errors import SpeechWeaveError
12
+ from speechweave.version import __version__
13
+
14
+ _UPLOAD_CHUNK_SIZE = 64 * 1024
15
+
16
+
17
+ def _normalize_base(url: str) -> str:
18
+
19
+ s = (url or "").strip().rstrip("/")
20
+
21
+ return s or "https://api.speechweave.com/v1"
22
+
23
+
24
+ async def _async_upload_content(data: UploadBody) -> bytes | AsyncIterator[bytes]:
25
+ """
26
+ Normalize upload body for `httpx.AsyncClient` (rejects sync file streams).
27
+ """
28
+ if isinstance(data, (bytes, bytearray, memoryview)):
29
+ return bytes(data)
30
+
31
+ async def _chunks() -> AsyncIterator[bytes]:
32
+ while True:
33
+ chunk = await asyncio.to_thread(data.read, _UPLOAD_CHUNK_SIZE)
34
+ if not chunk:
35
+ break
36
+ yield chunk
37
+
38
+ return _chunks()
39
+
40
+
41
+ class AsyncSpeechWeaveClient:
42
+ """
43
+ Async SpeechWeave `/v1` client (presign, jobs, uploads).
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ api_key: str | None = None,
49
+ *,
50
+ base_url: str | None = None,
51
+ timeout: float = 120.0,
52
+ client: httpx.AsyncClient | None = None,
53
+ ):
54
+ """
55
+ Args:
56
+ api_key: Falls back to `SPEECHWEAVE_API_KEY`. Raises if neither is set.
57
+ base_url: Defaults to `https://api.speechweave.com/v1`.
58
+ timeout: httpx timeout in seconds when constructing a new client.
59
+ client: Injected `httpx.AsyncClient`; caller owns `aclose` if provided.
60
+ """
61
+
62
+ self.api_key = api_key or os.environ.get("SPEECHWEAVE_API_KEY") or ""
63
+ self.base_url = _normalize_base(base_url or "")
64
+
65
+ if not self.api_key:
66
+ raise ValueError("SpeechWeave API key is required (api_key or SPEECHWEAVE_API_KEY)")
67
+
68
+ self._owns_client = client is None
69
+ self._client = client or httpx.AsyncClient(timeout=timeout)
70
+
71
+ async def aclose(self) -> None:
72
+ """
73
+ Close the owned httpx client. No-op when a client was injected.
74
+ """
75
+ if self._owns_client:
76
+ await self._client.aclose()
77
+
78
+ async def __aenter__(self) -> AsyncSpeechWeaveClient:
79
+ return self
80
+
81
+ async def __aexit__(
82
+ self,
83
+ *args: object,
84
+ ) -> None:
85
+ await self.aclose()
86
+
87
+ def _auth_headers(
88
+ self,
89
+ json_body: bool = False,
90
+ ) -> dict[str, str]:
91
+
92
+ h: dict[str, str] = {
93
+ "Authorization": f"Bearer {self.api_key}",
94
+ "Accept": "application/json",
95
+ "User-Agent": f"speechweave-python/{__version__}",
96
+ }
97
+
98
+ if json_body:
99
+ h["Content-Type"] = "application/json"
100
+
101
+ return h
102
+
103
+ def _url(
104
+ self,
105
+ path: str,
106
+ ) -> str:
107
+
108
+ p = path if path.startswith("/") else f"/{path}"
109
+
110
+ return f"{self.base_url}{p}"
111
+
112
+ async def request_json(
113
+ self,
114
+ method: str,
115
+ path: str,
116
+ json: Mapping[str, Any] | None = None,
117
+ params: Mapping[str, Any] | None = None,
118
+ ) -> Any:
119
+ """
120
+ JSON request against `base_url`. Raises `SpeechWeaveError` on HTTP >= 400.
121
+
122
+ Args:
123
+ path: Relative to `base_url` (leading `/` optional).
124
+ """
125
+
126
+ r = await self._client.request(
127
+ method,
128
+ self._url(path),
129
+ headers=self._auth_headers(json_body=json is not None),
130
+ json=json,
131
+ params=params,
132
+ )
133
+
134
+ if r.status_code >= 400:
135
+ body = None
136
+
137
+ try:
138
+ body = r.json()
139
+ msg = str(body.get("error") or body.get("message") or r.text)
140
+ code = body.get("code")
141
+ retry_after = body.get("retry_after")
142
+ if retry_after is None and r.headers.get("Retry-After"):
143
+ try:
144
+ retry_after = int(r.headers.get("Retry-After"))
145
+ except (TypeError, ValueError):
146
+ retry_after = None
147
+ except Exception:
148
+ msg = r.text or r.reason_phrase
149
+ code = str(r.status_code)
150
+ retry_after = None
151
+ raise SpeechWeaveError(
152
+ msg,
153
+ r.status_code,
154
+ str(code) if code is not None else str(r.status_code),
155
+ body=body,
156
+ retry_after=retry_after if isinstance(retry_after, int) else None,
157
+ )
158
+
159
+ if r.status_code == 204 or not r.content:
160
+ return None
161
+
162
+ return r.json()
163
+
164
+ async def raw_request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ **kwargs: Any,
169
+ ) -> httpx.Response:
170
+ """
171
+ Authenticated request returning the raw `httpx.Response`.
172
+
173
+ Merges Bearer / Accept / User-Agent; caller-supplied headers win on conflict.
174
+ """
175
+
176
+ headers = kwargs.pop("headers", {}) or {}
177
+ merged = {**self._auth_headers(json_body=False), **headers}
178
+
179
+ return await self._client.request(
180
+ method,
181
+ self._url(path),
182
+ headers=merged,
183
+ **kwargs,
184
+ )
185
+
186
+ async def presign_upload(
187
+ self,
188
+ *,
189
+ filename: str,
190
+ content_type: str,
191
+ ) -> dict[str, Any]:
192
+ """
193
+ Request a short-lived PUT URL and `object_key` for direct upload.
194
+
195
+ Args:
196
+ filename: Original name (used in the storage key).
197
+ content_type: MIME type that must match the subsequent PUT.
198
+ """
199
+
200
+ return await self.request_json(
201
+ "POST",
202
+ "/uploads",
203
+ {"filename": filename, "content_type": content_type},
204
+ )
205
+
206
+ async def put_presigned_url(
207
+ self,
208
+ upload_url: str,
209
+ data: UploadBody,
210
+ content_type: str,
211
+ *,
212
+ file_size: int | None = None,
213
+ ) -> None:
214
+ """
215
+ PUT audio bytes to a presigned `upload_url`.
216
+
217
+ Sync file objects are read off-thread in 64 KiB chunks (`AsyncClient`
218
+ rejects sync streams). Pass `file_size` when length cannot be measured.
219
+
220
+ Args:
221
+ upload_url: `upload_url` from `presign_upload`.
222
+ file_size: Explicit byte length when length cannot be measured.
223
+ """
224
+
225
+ headers = _upload_headers(content_type, data, file_size=file_size)
226
+ content = await _async_upload_content(data)
227
+ r = await self._client.put(
228
+ upload_url,
229
+ content=content,
230
+ headers=headers,
231
+ )
232
+
233
+ if r.status_code >= 400:
234
+ raise SpeechWeaveError(f"R2 upload failed: {r.text}", r.status_code, "UPLOAD_FAILED")
235
+
236
+ async def create_job(
237
+ self,
238
+ body: MutableMapping[str, Any],
239
+ ) -> dict[str, Any]:
240
+ """
241
+ Create a transcription job from an uploaded object or remote URL.
242
+
243
+ Provide one of `object_key`, `input_url`, or `audio_url`. `type` defaults to
244
+ `transcription`. Omitting `service_mode` leaves the API default (deferred).
245
+ Synchronous rejects files over the sync size cap (default 512 MiB).
246
+
247
+ Args:
248
+ body: Job fields. `object_key` is from a prior presign after a PUT;
249
+ `input_url` / `audio_url` are publicly reachable audio URLs;
250
+ `language` is a two-letter ISO code (e.g. 'en', 'es').
251
+ """
252
+
253
+ payload: dict[str, Any] = {"type": body.get("type") or "transcription"}
254
+ for key in (
255
+ "object_key",
256
+ "input_url",
257
+ "audio_url",
258
+ "model",
259
+ "service_mode",
260
+ "language",
261
+ "metadata",
262
+ ):
263
+ if key in body and body[key] is not None:
264
+ payload[key] = body[key]
265
+
266
+ return await self.request_json("POST", "/jobs", payload)
267
+
268
+ async def get_job(
269
+ self,
270
+ job_id: str,
271
+ ) -> dict[str, Any]:
272
+ """
273
+ Fetch the current job record (status, transcript when completed).
274
+
275
+ Args:
276
+ job_id: Id from `create_job` / `transcribe_file`.
277
+ """
278
+ return await self.request_json("GET", f"/jobs/{job_id}")
279
+
280
+ async def list_jobs(
281
+ self,
282
+ *,
283
+ page: int | None = None,
284
+ limit: int | None = None,
285
+ status: str | None = None,
286
+ ) -> dict[str, Any]:
287
+ """
288
+ List jobs for the authenticated account.
289
+
290
+ Args:
291
+ page: 1-based page; API default if omitted.
292
+ limit: Page size; API default if omitted.
293
+ status: Filter by job status (queued, processing, completed, …).
294
+ """
295
+ params: dict[str, Any] = {}
296
+ if page is not None:
297
+ params["page"] = page
298
+ if limit is not None:
299
+ params["limit"] = limit
300
+ if status is not None:
301
+ params["status"] = status
302
+
303
+ return await self.request_json("GET", "/jobs", params=params or None)
304
+
305
+ async def cancel_job(
306
+ self,
307
+ job_id: str,
308
+ ) -> dict[str, Any]:
309
+ """
310
+ Cancel a pending or processing job.
311
+
312
+ Fails if the job is already completed, failed, or cancelled.
313
+
314
+ Args:
315
+ job_id: Id from `create_job` / `transcribe_file`.
316
+ """
317
+ return await self.request_json("POST", f"/jobs/{job_id}/cancel", {})
318
+
319
+ async def transcribe_file(
320
+ self,
321
+ file_obj: BinaryIO,
322
+ *,
323
+ filename: str = "audio.bin",
324
+ content_type: str = "application/octet-stream",
325
+ model: str | None = None,
326
+ service_mode: str | None = None,
327
+ language: str | None = None,
328
+ metadata: dict[str, Any] | None = None,
329
+ file_size: int | None = None,
330
+ ) -> dict[str, Any]:
331
+ """
332
+ Presign → PUT → create job.
333
+
334
+ Returns the create ack (no transcript); poll `get_job` or `async_wait_for_job`.
335
+ Sync file objects are streamed off-thread. Omitting `service_mode` leaves
336
+ the API default (deferred). Synchronous rejects files over the sync size
337
+ cap (default 512 MiB).
338
+
339
+ Args:
340
+ file_obj: Open binary file or buffer to upload.
341
+ filename: Defaults to `audio.bin`.
342
+ content_type: Defaults to `application/octet-stream`.
343
+ language: Two-letter ISO code (e.g. 'en', 'es').
344
+ file_size: `Content-Length` when the body cannot be measured.
345
+ """
346
+
347
+ presign = await self.presign_upload(
348
+ filename=filename,
349
+ content_type=content_type,
350
+ )
351
+ await self.put_presigned_url(
352
+ presign["upload_url"],
353
+ file_obj,
354
+ content_type,
355
+ file_size=file_size,
356
+ )
357
+ body: dict[str, Any] = {"object_key": presign["object_key"]}
358
+
359
+ if model:
360
+ body["model"] = model
361
+ if service_mode:
362
+ body["service_mode"] = service_mode
363
+ if language:
364
+ body["language"] = language
365
+ if metadata:
366
+ body["metadata"] = metadata
367
+
368
+ return await self.create_job(body)