vocalstack 1.0.0a0__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.
- vocalstack/SKILL.md +235 -0
- vocalstack/__init__.py +63 -0
- vocalstack/_http.py +157 -0
- vocalstack/_models.py +1745 -0
- vocalstack/client.py +107 -0
- vocalstack/errors.py +83 -0
- vocalstack/realtime.py +411 -0
- vocalstack/resources.py +835 -0
- vocalstack-1.0.0a0.dist-info/METADATA +117 -0
- vocalstack-1.0.0a0.dist-info/RECORD +11 -0
- vocalstack-1.0.0a0.dist-info/WHEEL +4 -0
vocalstack/SKILL.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vocalstack
|
|
3
|
+
description: Use when writing Python code that calls the VocalStack speech API with the vocalstack package, for example to transcribe an audio or video URL or a local file, stream live audio for realtime transcription, fetch or export a transcript (JSON, SRT, XLSX, PDF), or search transcripts. Covers installing, API keys, base URLs, five copy-paste programs and error handling (401, 402, 403, 429).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# VocalStack Python SDK
|
|
7
|
+
|
|
8
|
+
The VocalStack API transcribes speech (files, links, live audio), with speaker
|
|
9
|
+
labels and translation. The `vocalstack` package has one asyncio client,
|
|
10
|
+
`VocalStack`, with one group per resource: `vs.transcriptions`, `vs.jobs`,
|
|
11
|
+
`vs.recordings`, `vs.broadcasts`, `vs.search`, `vs.realtime` and a few more
|
|
12
|
+
(listed at the end). Answers are pydantic models with snake_case attributes.
|
|
13
|
+
|
|
14
|
+
## What is what
|
|
15
|
+
|
|
16
|
+
- A **transcription** is a file, a link, a microphone or a stream, transcribed
|
|
17
|
+
and stored. Create one with `await vs.transcriptions.create(...)`.
|
|
18
|
+
- A **recording** is one take inside it. Its id (`recording_id`) is what you
|
|
19
|
+
read the transcript and exports with: `await vs.recordings.transcript(recording_id)`.
|
|
20
|
+
- A **job** is a file or link recording's transcription work:
|
|
21
|
+
`await vs.jobs.wait(recording_id)` polls it until it is `done` or `failed`
|
|
22
|
+
(`on_progress=lambda job: ...` sees each read: `job.state`, `job.progress.pct`).
|
|
23
|
+
- A **broadcast** is a link listeners open to follow a live recording.
|
|
24
|
+
- A **realtime session** (`vs.realtime`) is a socket you stream audio into and
|
|
25
|
+
read the transcript from. Nothing is stored. "Session" means only this.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python3 -m venv .venv && . .venv/bin/activate # system Pythons refuse a bare pip install
|
|
31
|
+
pip install vocalstack # Python 3.10 or newer
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Before the package is published, any account installs
|
|
35
|
+
the private build its environment serves: the API reference's Install section
|
|
36
|
+
(`<base>/docs`, or Developers -> API reference in the app) and `<base>/sdk/latest`
|
|
37
|
+
give the exact commands, which send `VOCALSTACK_API_KEY` to that host only.
|
|
38
|
+
Or install it from a local checkout of the repository:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install -e /absolute/path/to/transcribe/sdks/python
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Live transcription (program 4) also needs `ffmpeg` on PATH.
|
|
45
|
+
|
|
46
|
+
Speaker numbers start at 0. A speaker of `-1` means speaker labels were off
|
|
47
|
+
for that transcription, so print the text without a speaker.
|
|
48
|
+
|
|
49
|
+
## Auth and base URL
|
|
50
|
+
|
|
51
|
+
- Read the key from `VOCALSTACK_API_KEY` and pass it as `api_key`. It goes
|
|
52
|
+
out as `Authorization: Bearer <key>`, also on sockets.
|
|
53
|
+
- An API key starts with `vs_key_`. There is one kind of key: every call is
|
|
54
|
+
real usage on the account's tier and quotas.
|
|
55
|
+
- Keys are for servers. Never put a key in browser code or in a URL. The API
|
|
56
|
+
refuses a key sent from a web page (`Origin` header) or in a query string.
|
|
57
|
+
- Scopes: `read` (read transcripts, search, export), `write` and
|
|
58
|
+
`recordings:publish` (create transcriptions, stream live audio), or `*`.
|
|
59
|
+
- Base URL: `https://api.vocalstack.com` (the default). Override it with
|
|
60
|
+
`VOCALSTACK_BASE_URL`: `http://127.0.0.1:8090` for a local server,
|
|
61
|
+
`https://api.e2e.vocalstack.com` for e2e.
|
|
62
|
+
|
|
63
|
+
Every program below opens the client the same way, inside `async def main()`
|
|
64
|
+
run with `asyncio.run(main())` (a `None` base URL means the default):
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
async with VocalStack(os.environ.get("VOCALSTACK_BASE_URL"), api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
68
|
+
...
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## 1. Transcribe a URL and wait for the result
|
|
72
|
+
|
|
73
|
+
The server fetches the link itself, so it must be a public http(s) address.
|
|
74
|
+
Private, loopback and cloud-metadata addresses are refused.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio, os
|
|
78
|
+
from vocalstack import VocalStack
|
|
79
|
+
|
|
80
|
+
async def main():
|
|
81
|
+
async with VocalStack(os.environ.get("VOCALSTACK_BASE_URL"), api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
82
|
+
tx = await vs.transcriptions.create(
|
|
83
|
+
url="https://raw.githubusercontent.com/openai/whisper/main/tests/jfk.flac",
|
|
84
|
+
name="JFK sample",
|
|
85
|
+
)
|
|
86
|
+
rec = tx.job.recording_id
|
|
87
|
+
job = await vs.jobs.wait(rec) # queued -> fetching -> transcribing -> done | failed
|
|
88
|
+
if job.state != "done":
|
|
89
|
+
raise SystemExit(f"transcription {job.state}: {job.error}")
|
|
90
|
+
snap = await vs.recordings.transcript(rec)
|
|
91
|
+
for s in snap.sentences:
|
|
92
|
+
print(f"[speaker {s.speaker}] {s.text}" if s.speaker >= 0 else s.text)
|
|
93
|
+
print("recording_id", rec)
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 2. Transcribe a local file
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import asyncio, os, sys
|
|
102
|
+
from vocalstack import VocalStack
|
|
103
|
+
|
|
104
|
+
async def main(path): # mp3, wav, flac, ogg, m4a, mp4 ...
|
|
105
|
+
async with VocalStack(os.environ.get("VOCALSTACK_BASE_URL"), api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
106
|
+
tx = await vs.transcriptions.create(
|
|
107
|
+
file=path, # a path, bytes, or an open binary file
|
|
108
|
+
settings={"spokenLanguage": "en", "speakerLabels": "on"}, # both optional; "auto" | "on" | "off"
|
|
109
|
+
)
|
|
110
|
+
job = await vs.jobs.wait(tx.job.recording_id)
|
|
111
|
+
if job.state != "done":
|
|
112
|
+
raise SystemExit(f"transcription {job.state}: {job.error}")
|
|
113
|
+
snap = await vs.recordings.transcript(job.recording_id)
|
|
114
|
+
print(" ".join(s.text for s in snap.sentences))
|
|
115
|
+
|
|
116
|
+
asyncio.run(main(sys.argv[1]))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 3. Fetch a transcript or an export file
|
|
120
|
+
|
|
121
|
+
The transcript holds segments (with word timings), sentences and speakers.
|
|
122
|
+
Export formats are `json`, `srt`, `xlsx` and `pdf`; add `lang="<code>"` for a
|
|
123
|
+
translated file. `export` returns the file's bytes.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import asyncio, os, sys
|
|
127
|
+
from vocalstack import VocalStack
|
|
128
|
+
|
|
129
|
+
async def main(rec):
|
|
130
|
+
async with VocalStack(os.environ.get("VOCALSTACK_BASE_URL"), api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
131
|
+
snap = await vs.recordings.transcript(rec)
|
|
132
|
+
print(len(snap.segments), "segments,", len(snap.sentences), "sentences, state", snap.state)
|
|
133
|
+
with open("transcript.srt", "wb") as f:
|
|
134
|
+
f.write(await vs.recordings.export(rec, "srt"))
|
|
135
|
+
|
|
136
|
+
asyncio.run(main(sys.argv[1]))
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 4. Live transcription from a microphone or a file
|
|
140
|
+
|
|
141
|
+
A live transcription takes raw PCM: 32-bit float little-endian, 16 kHz, mono.
|
|
142
|
+
Create it, start a recording, and open the publisher socket the grant names.
|
|
143
|
+
Here ffmpeg decodes a file, and `pace_realtime` sends it at realtime speed and
|
|
144
|
+
then calls `eof()`. For a microphone, read ffmpeg's stdout as it arrives
|
|
145
|
+
(`-f avfoundation -i :0` on macOS, `-f pulse -i default` on Linux) and call
|
|
146
|
+
`await stream.send_audio(chunk)` for each chunk.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import asyncio, os, subprocess, sys
|
|
150
|
+
from vocalstack import VocalStack, pace_realtime
|
|
151
|
+
|
|
152
|
+
async def main(path):
|
|
153
|
+
async with VocalStack(os.environ.get("VOCALSTACK_BASE_URL"), api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
154
|
+
tx = await vs.transcriptions.create_live(name="Live demo", settings={"spokenLanguage": "en"})
|
|
155
|
+
grant = await vs.transcriptions.start_recording(tx.id)
|
|
156
|
+
|
|
157
|
+
pcm = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", path,
|
|
158
|
+
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"],
|
|
159
|
+
check=True, capture_output=True).stdout
|
|
160
|
+
|
|
161
|
+
# A cold engine can take a while to load, so allow a long ready deadline.
|
|
162
|
+
async with await vs.recordings.publish(grant, ready_deadline_s=180) as stream:
|
|
163
|
+
feeder = asyncio.create_task(pace_realtime(stream, pcm)) # then eof(), which ends the recording
|
|
164
|
+
async for event in stream:
|
|
165
|
+
if event.type == "transcript.committed":
|
|
166
|
+
print(f"[speaker {event.segment.speaker}] {event.segment.text}", flush=True)
|
|
167
|
+
await feeder
|
|
168
|
+
|
|
169
|
+
# To end a recording without eof(): vs.recordings.pause(id), then vs.recordings.stop(id).
|
|
170
|
+
snap = await vs.recordings.transcript(grant.recording.id)
|
|
171
|
+
print("final:", " ".join(s.text for s in snap.sentences))
|
|
172
|
+
|
|
173
|
+
asyncio.run(main(sys.argv[1]))
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
To transcribe without storing anything, use a realtime session instead:
|
|
177
|
+
`(await vs.realtime.transcribe_file("talk.mp3")).text`.
|
|
178
|
+
|
|
179
|
+
## 5. Search transcripts
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
import asyncio, os, sys
|
|
183
|
+
from vocalstack import VocalStack
|
|
184
|
+
|
|
185
|
+
async def main(q): # 2+ characters
|
|
186
|
+
async with VocalStack(os.environ.get("VOCALSTACK_BASE_URL"), api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
|
|
187
|
+
res = await vs.search.query(q, limit=5)
|
|
188
|
+
for h in res.hits:
|
|
189
|
+
print(f"{h.link_name} @ {h.media_start:.1f}s [speaker {h.speaker}]: {h.text} (recording {h.recording_id})")
|
|
190
|
+
|
|
191
|
+
asyncio.run(main(sys.argv[1]))
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Errors
|
|
195
|
+
|
|
196
|
+
A failed call raises `vocalstack.VocalStackError` with `.code`, `.message`,
|
|
197
|
+
`.status`, `.retryable`, `.details` (the body's `data.details`), `.headers`
|
|
198
|
+
and `.retry_after` (seconds, from `Retry-After`). A `429`, and a
|
|
199
|
+
`503 unavailable` on a call that is safe to repeat, is retried for you after
|
|
200
|
+
its `Retry-After`, twice by default (`max_retries` on the client).
|
|
201
|
+
|
|
202
|
+
| Status | `code` | What to do |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| 401 | `invalid_token`, `key_expired`, `key_revoked` | Check `VOCALSTACK_API_KEY`. Use the replacement key, or create a new one. |
|
|
205
|
+
| 403 | `forbidden` | Usually a missing scope: the message names it, and `e.headers["www-authenticate"]` lists the scopes the route needs. A suspended key or account gets `suspended`. |
|
|
206
|
+
| 402 | `insufficient_credit` | The account's credit is out. `details`: `balance`, `plan`, `topUpUrl`, and `required` when the cost was known up front. Add credit; `await vs.billing.get()` shows the balance. |
|
|
207
|
+
| 409 | `payment_method_required` | Auto top-up (`vs.billing.set_auto_top_up`) and the portal (`vs.billing.portal()`) need a payment first (`vs.billing.create_checkout(kind="topup", amount=25, accept_terms=True, waive_withdrawal=True)` opens one; a signed-in person's token, never an API key). |
|
|
208
|
+
| 402 | `quota_exceeded` | The plan's transcriptions-at-once limit is reached. `details`: `metric` (`concurrent_transcriptions`), `limit`, `used`, `plan`, `topUpUrl`. |
|
|
209
|
+
| 429 | `rate_limited` | Retried automatically; if it still fails, wait `e.retry_after` seconds. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
|
|
210
|
+
| 503 | `unavailable` | The service is waking (its database was asleep). Retried automatically after `Retry-After` (2 s) on calls that are safe to repeat: GET, HEAD, DELETE, or any call with `idempotency_key=`. A create without a key is not retried: retry it yourself, with an `idempotency_key` so it cannot run twice. A realtime socket refused this way raises `unavailable`; a bare 503 there is `capacity_exceeded`. |
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
from vocalstack import VocalStackError
|
|
214
|
+
try:
|
|
215
|
+
await vs.search.query("country")
|
|
216
|
+
except VocalStackError as e:
|
|
217
|
+
if e.code in ("insufficient_credit", "quota_exceeded"):
|
|
218
|
+
print("billing:", e.message, e.details)
|
|
219
|
+
else:
|
|
220
|
+
print(e.status, e.code, e.message)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## More
|
|
224
|
+
|
|
225
|
+
- The other groups: `vs.transcriptions` (`list`, `get`, `update`, `archive`,
|
|
226
|
+
`restore`, `purge`, `list_share_links`, `create_share_link`), `vs.recordings`
|
|
227
|
+
(`get`, `summarize`, `summary`, `translation`, `audio_url`, `subscribe`),
|
|
228
|
+
`vs.corrections`, `vs.share_links`, `vs.voices`, `vs.broadcasts`,
|
|
229
|
+
`vs.me` (`get`, `usage`), `vs.languages`, `vs.rtmp`, `vs.events`, `vs.meta`.
|
|
230
|
+
Each method is its operation's name in the OpenAPI document, in snake_case
|
|
231
|
+
(`transcriptions.create` is `POST /v1/transcriptions`).
|
|
232
|
+
- `<base>/llms.txt` lists every operation in a form written for agents.
|
|
233
|
+
`<base>/llms-full.txt` adds parameters, auth mode, tier and scopes.
|
|
234
|
+
- `<base>/docs` is the interactive reference. `<base>/v1/openapi.json` is
|
|
235
|
+
the OpenAPI document; the SDK's models are generated from it.
|
vocalstack/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""VocalStack speech API SDK (asyncio): one client, ``VocalStack``, grouped
|
|
2
|
+
by resource, with models generated from the API's one contract."""
|
|
3
|
+
|
|
4
|
+
from . import _models as models
|
|
5
|
+
from ._models import (
|
|
6
|
+
Broadcast,
|
|
7
|
+
Capabilities,
|
|
8
|
+
CommittedSegment,
|
|
9
|
+
CreateSessionResponse,
|
|
10
|
+
DraftSegment,
|
|
11
|
+
Health,
|
|
12
|
+
PublishGrant,
|
|
13
|
+
Recording,
|
|
14
|
+
SearchResults,
|
|
15
|
+
ServerFrame,
|
|
16
|
+
SessionOptions,
|
|
17
|
+
SessionStats,
|
|
18
|
+
Transcription,
|
|
19
|
+
TranscriptionJob,
|
|
20
|
+
TranscriptCommitted,
|
|
21
|
+
TranscriptDraft,
|
|
22
|
+
TranscriptProgress,
|
|
23
|
+
TranscriptSnapshot,
|
|
24
|
+
TranscriptWords,
|
|
25
|
+
Word,
|
|
26
|
+
)
|
|
27
|
+
from ._http import DEFAULT_BASE_URL
|
|
28
|
+
from .client import VocalStack
|
|
29
|
+
from .errors import VocalStackError
|
|
30
|
+
from .realtime import RealtimeTranscript
|
|
31
|
+
from .realtime import RealtimeSession, pace_realtime
|
|
32
|
+
|
|
33
|
+
__version__ = "1.0.0a0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DEFAULT_BASE_URL",
|
|
37
|
+
"Broadcast",
|
|
38
|
+
"Capabilities",
|
|
39
|
+
"CommittedSegment",
|
|
40
|
+
"CreateSessionResponse",
|
|
41
|
+
"DraftSegment",
|
|
42
|
+
"Health",
|
|
43
|
+
"PublishGrant",
|
|
44
|
+
"RealtimeTranscript",
|
|
45
|
+
"Recording",
|
|
46
|
+
"SearchResults",
|
|
47
|
+
"ServerFrame",
|
|
48
|
+
"SessionOptions",
|
|
49
|
+
"SessionStats",
|
|
50
|
+
"RealtimeSession",
|
|
51
|
+
"Transcription",
|
|
52
|
+
"TranscriptionJob",
|
|
53
|
+
"TranscriptCommitted",
|
|
54
|
+
"TranscriptDraft",
|
|
55
|
+
"TranscriptProgress",
|
|
56
|
+
"TranscriptSnapshot",
|
|
57
|
+
"TranscriptWords",
|
|
58
|
+
"VocalStack",
|
|
59
|
+
"VocalStackError",
|
|
60
|
+
"Word",
|
|
61
|
+
"models",
|
|
62
|
+
"pace_realtime",
|
|
63
|
+
]
|
vocalstack/_http.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The one HTTP path every resource method goes through."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Union
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .errors import from_envelope
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.vocalstack.com"
|
|
14
|
+
|
|
15
|
+
Query = Mapping[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _clean(q: Optional[Query]) -> Dict[str, str]:
|
|
19
|
+
out: Dict[str, str] = {}
|
|
20
|
+
for k, v in (q or {}).items():
|
|
21
|
+
if v is None or v == "":
|
|
22
|
+
continue
|
|
23
|
+
out[k] = ("true" if v else "false") if isinstance(v, bool) else str(v)
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def listener_query(listener_token: Optional[str], share_code: Optional[str]) -> Dict[str, Any]:
|
|
28
|
+
"""The query of a listener-side read (a private broadcast's listener
|
|
29
|
+
token, or a share link's code), named as on the wire."""
|
|
30
|
+
return {"listenerToken": listener_token, "shareCode": share_code}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: The wait before retrying ``503 unavailable`` when it names none.
|
|
34
|
+
UNAVAILABLE_WAIT_S = 2.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def retries(status: int, code: str, method: str, idempotency_key: Optional[str] = None) -> bool:
|
|
38
|
+
"""Whether a failed call is retried after its ``Retry-After`` (up to
|
|
39
|
+
``max_retries`` times): ``429 rate_limited`` always (nothing ran), and
|
|
40
|
+
``503 unavailable`` (the database is waking) only when repeating the call
|
|
41
|
+
cannot do the work twice: GET, HEAD and DELETE, or a call carrying an
|
|
42
|
+
``Idempotency-Key``."""
|
|
43
|
+
if status == 429:
|
|
44
|
+
return True
|
|
45
|
+
if status != 503 or code != "unavailable":
|
|
46
|
+
return False
|
|
47
|
+
return method.upper() in ("GET", "HEAD", "DELETE") or bool(idempotency_key)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Transport:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
base_url: Optional[str],
|
|
54
|
+
api_key: Optional[str],
|
|
55
|
+
token: Union[None, str, Callable[[], Optional[str]]],
|
|
56
|
+
*,
|
|
57
|
+
http: Optional[httpx.AsyncClient],
|
|
58
|
+
acting_principal: Optional[str],
|
|
59
|
+
device_id: Optional[str],
|
|
60
|
+
max_retries: int,
|
|
61
|
+
timeout: float,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
64
|
+
if api_key and token:
|
|
65
|
+
raise TypeError("VocalStack: pass api_key (a server) or token (a browser), not both")
|
|
66
|
+
self._api_key = api_key
|
|
67
|
+
self._token = token
|
|
68
|
+
self.own_http = http is None
|
|
69
|
+
self.http = http or httpx.AsyncClient(timeout=timeout)
|
|
70
|
+
self.acting_principal = acting_principal
|
|
71
|
+
self.device_id = device_id
|
|
72
|
+
self.max_retries = max_retries
|
|
73
|
+
|
|
74
|
+
def bearer(self) -> Optional[str]:
|
|
75
|
+
"""What goes in ``Authorization: Bearer``: the API key, else the token."""
|
|
76
|
+
if self._api_key:
|
|
77
|
+
return self._api_key
|
|
78
|
+
t = self._token
|
|
79
|
+
return t() if callable(t) else t
|
|
80
|
+
|
|
81
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
82
|
+
"""The credential as a header (sockets too: a key is refused in a URL)."""
|
|
83
|
+
key = self.bearer()
|
|
84
|
+
return {"authorization": f"Bearer {key}"} if key else {}
|
|
85
|
+
|
|
86
|
+
def url(self, path: str) -> str:
|
|
87
|
+
return self.base_url + path
|
|
88
|
+
|
|
89
|
+
def ws_url(self, path: str, query: Optional[Query] = None) -> str:
|
|
90
|
+
base = "ws" + self.base_url[4:] if self.base_url.startswith("http") else self.base_url
|
|
91
|
+
q = _clean(query)
|
|
92
|
+
return base + path + (("?" + str(httpx.QueryParams(q))) if q else "")
|
|
93
|
+
|
|
94
|
+
async def raw(
|
|
95
|
+
self,
|
|
96
|
+
method: str,
|
|
97
|
+
path: str,
|
|
98
|
+
*,
|
|
99
|
+
query: Optional[Query] = None,
|
|
100
|
+
json_body: Any = None,
|
|
101
|
+
files: Any = None,
|
|
102
|
+
data: Any = None,
|
|
103
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
104
|
+
idempotency_key: Optional[str] = None,
|
|
105
|
+
anonymous: bool = False,
|
|
106
|
+
) -> httpx.Response:
|
|
107
|
+
h: Dict[str, str] = {"accept": "application/json"}
|
|
108
|
+
if headers:
|
|
109
|
+
h.update(headers)
|
|
110
|
+
key = None if anonymous else self.bearer()
|
|
111
|
+
if key:
|
|
112
|
+
h["authorization"] = f"Bearer {key}"
|
|
113
|
+
if self.acting_principal:
|
|
114
|
+
h["x-acting-principal"] = self.acting_principal
|
|
115
|
+
if self.device_id:
|
|
116
|
+
h["x-device-id"] = self.device_id
|
|
117
|
+
if idempotency_key:
|
|
118
|
+
h["idempotency-key"] = idempotency_key
|
|
119
|
+
attempt = 0
|
|
120
|
+
while True:
|
|
121
|
+
resp = await self.http.request(
|
|
122
|
+
method,
|
|
123
|
+
self.url(path),
|
|
124
|
+
params=_clean(query),
|
|
125
|
+
json=json_body,
|
|
126
|
+
files=files,
|
|
127
|
+
data=data,
|
|
128
|
+
headers=h,
|
|
129
|
+
follow_redirects=False,
|
|
130
|
+
)
|
|
131
|
+
if resp.status_code < 400:
|
|
132
|
+
return resp
|
|
133
|
+
body = None
|
|
134
|
+
try:
|
|
135
|
+
body = resp.json()
|
|
136
|
+
except (json.JSONDecodeError, ValueError):
|
|
137
|
+
pass
|
|
138
|
+
err = from_envelope(body, resp.status_code, resp.headers)
|
|
139
|
+
if retries(resp.status_code, err.code, method, idempotency_key) and attempt < self.max_retries:
|
|
140
|
+
wait = err.retry_after
|
|
141
|
+
if wait is None and err.code == "unavailable":
|
|
142
|
+
wait = UNAVAILABLE_WAIT_S
|
|
143
|
+
if wait is not None and wait <= 60:
|
|
144
|
+
attempt += 1
|
|
145
|
+
await asyncio.sleep(wait)
|
|
146
|
+
continue
|
|
147
|
+
raise err
|
|
148
|
+
|
|
149
|
+
async def json(self, method: str, path: str, **kw: Any) -> Any:
|
|
150
|
+
resp = await self.raw(method, path, **kw)
|
|
151
|
+
if 300 <= resp.status_code < 400:
|
|
152
|
+
return {"location": resp.headers.get("location")}
|
|
153
|
+
return resp.json() if resp.content else None
|
|
154
|
+
|
|
155
|
+
async def aclose(self) -> None:
|
|
156
|
+
if self.own_http:
|
|
157
|
+
await self.http.aclose()
|