avartha-python-sdk 0.0.2__py3-none-any.whl → 0.0.4__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.
- avartha/__init__.py +2 -1
- avartha/_exceptions.py +5 -1
- avartha/_http_errors.py +16 -0
- avartha/_realtime_tts.py +17 -26
- avartha/_speech_config.py +2 -17
- avartha/_speech_http.py +29 -26
- avartha/client.py +2 -0
- avartha/control.py +251 -23
- avartha_python_sdk-0.0.4.dist-info/METADATA +140 -0
- {avartha_python_sdk-0.0.2.dist-info → avartha_python_sdk-0.0.4.dist-info}/RECORD +14 -14
- {avartha_python_sdk-0.0.2.dist-info → avartha_python_sdk-0.0.4.dist-info}/licenses/THIRD_PARTY_NOTICES +9 -9
- avartha/_version.py +0 -24
- avartha_python_sdk-0.0.2.dist-info/METADATA +0 -426
- {avartha_python_sdk-0.0.2.dist-info → avartha_python_sdk-0.0.4.dist-info}/WHEEL +0 -0
- {avartha_python_sdk-0.0.2.dist-info → avartha_python_sdk-0.0.4.dist-info}/licenses/LICENSE +0 -0
- {avartha_python_sdk-0.0.2.dist-info → avartha_python_sdk-0.0.4.dist-info}/top_level.txt +0 -0
avartha/__init__.py
CHANGED
|
@@ -28,7 +28,7 @@ from ._openai_http_errors import (
|
|
|
28
28
|
)
|
|
29
29
|
from ._openai_http_errors import UnprocessableEntityError as OpenAIUnprocessableEntityError
|
|
30
30
|
from ._pacing import WebSocketLimits
|
|
31
|
-
from ._realtime_asr import AudioFormat, CommitStrategy, RealtimeEvents
|
|
31
|
+
from ._realtime_asr import AudioFormat, CommitStrategy, RealtimeAudioOptions, RealtimeEvents
|
|
32
32
|
from ._realtime_tts import DEFAULT_OUTPUT_FORMAT as DEFAULT_TTS_OUTPUT_FORMAT
|
|
33
33
|
from ._realtime_tts import AsyncMultiContextSession, MultiContextSession, audio_bytes
|
|
34
34
|
from ._sentinels import NOT_GIVEN, NotGiven, Omit, not_given, omit
|
|
@@ -89,6 +89,7 @@ __all__ = [
|
|
|
89
89
|
"MultiContextSession",
|
|
90
90
|
"OpenAI",
|
|
91
91
|
"PlatformAPIError",
|
|
92
|
+
"RealtimeAudioOptions",
|
|
92
93
|
"RealtimeEvents",
|
|
93
94
|
"VoiceSettings",
|
|
94
95
|
"WebSocketLimits",
|
avartha/_exceptions.py
CHANGED
|
@@ -22,7 +22,11 @@ class WebSocketConnectionClosedError(OpenAIError):
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class ApiError(Exception):
|
|
25
|
-
"""
|
|
25
|
+
"""Speech API error or speech/management HTTP request failure.
|
|
26
|
+
|
|
27
|
+
Server errors retain their response payload. HTTP request failures have no
|
|
28
|
+
status or headers and retain the underlying exception as ``__cause__``.
|
|
29
|
+
"""
|
|
26
30
|
|
|
27
31
|
def __init__(
|
|
28
32
|
self,
|
avartha/_http_errors.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Translate speech and management HTTP request failures to public API errors."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._exceptions import ApiError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def wrap_httpx_errors() -> Iterator[None]:
|
|
13
|
+
try:
|
|
14
|
+
yield
|
|
15
|
+
except httpx.RequestError as error:
|
|
16
|
+
raise ApiError(body=str(error) or type(error).__name__) from error
|
avartha/_realtime_tts.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Gateway-compatible sync and async realtime TTS helpers.
|
|
2
2
|
|
|
3
3
|
The pinned upstream helper omits `auto_mode` and sends generation controls the
|
|
4
|
-
managed gateway rejects. These clients share a compatible envelope,
|
|
5
|
-
|
|
4
|
+
managed gateway rejects. These clients share a compatible envelope, send
|
|
5
|
+
text fragments unchanged, preserve errors, and use owned multi-context message types.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
@@ -65,10 +65,6 @@ OMIT = cast(Any, ...)
|
|
|
65
65
|
# so their native PCM rate is the only workable default.
|
|
66
66
|
DEFAULT_OUTPUT_FORMAT: OutputFormat = "pcm_24000"
|
|
67
67
|
|
|
68
|
-
# Upstream's splitter set, copied because the chunk boundaries are wire
|
|
69
|
-
# behaviour and upstream ships no async chunker to reuse.
|
|
70
|
-
SPLITTERS = (".", ",", "?", "!", ";", ":", "—", "-", "(", ")", "[", "]", "}", " ")
|
|
71
|
-
|
|
72
68
|
|
|
73
69
|
def _given[T](value: T) -> T | None:
|
|
74
70
|
"""Upstream's OMIT sentinel means "not supplied" and must never reach the wire."""
|
|
@@ -130,7 +126,7 @@ def _close_text(iterator: Iterator[str], *, concurrent: bool = False) -> bool:
|
|
|
130
126
|
|
|
131
127
|
|
|
132
128
|
def _until_stopped(source: Iterator[str], stopped: Event) -> Iterator[str]:
|
|
133
|
-
"""
|
|
129
|
+
"""Stop reading caller input once the audio stream closes."""
|
|
134
130
|
while not stopped.is_set():
|
|
135
131
|
text = next(source, None)
|
|
136
132
|
if text is None or stopped.is_set():
|
|
@@ -171,22 +167,11 @@ async def _sync_chunks(chunks: Iterable[str]) -> AsyncGenerator[str]:
|
|
|
171
167
|
|
|
172
168
|
|
|
173
169
|
async def _achunks(chunks: AsyncIterable[str]) -> AsyncGenerator[str]:
|
|
174
|
-
"""
|
|
170
|
+
"""Pass text through unchanged and release the underlying async source."""
|
|
175
171
|
source = aiter(chunks)
|
|
176
|
-
buffer = ""
|
|
177
172
|
try:
|
|
178
173
|
async for text in source:
|
|
179
|
-
|
|
180
|
-
yield buffer if buffer.endswith(" ") else buffer + " "
|
|
181
|
-
buffer = text
|
|
182
|
-
elif text.startswith(SPLITTERS):
|
|
183
|
-
output = buffer + text[0]
|
|
184
|
-
yield output if output.endswith(" ") else output + " "
|
|
185
|
-
buffer = text[1:]
|
|
186
|
-
else:
|
|
187
|
-
buffer += text
|
|
188
|
-
if buffer:
|
|
189
|
-
yield buffer + " "
|
|
174
|
+
yield text
|
|
190
175
|
finally:
|
|
191
176
|
if close := getattr(source, "aclose", None):
|
|
192
177
|
await close()
|
|
@@ -217,6 +202,10 @@ class AvarthaRealtimeTextToSpeechClient:
|
|
|
217
202
|
) -> Iterator[bytes]:
|
|
218
203
|
"""Stream `text` to `voice_id` and yield raw audio frames.
|
|
219
204
|
|
|
205
|
+
Nonempty text fragments are forwarded unchanged, including whitespace.
|
|
206
|
+
The Avartha Platform handles buffering and segmentation. Empty items are
|
|
207
|
+
skipped; only iterator exhaustion sends EOF.
|
|
208
|
+
|
|
220
209
|
`output_format` defaults to `DEFAULT_OUTPUT_FORMAT`. `voice_settings` is
|
|
221
210
|
forwarded when supplied, but the gateway rejects any non-null value;
|
|
222
211
|
leave it unset unless the platform has gained support for it.
|
|
@@ -249,6 +238,9 @@ class AvarthaRealtimeTextToSpeechClient:
|
|
|
249
238
|
with closing(text_chunker(_until_stopped(text, stopped))) as chunks:
|
|
250
239
|
while not stopped.is_set():
|
|
251
240
|
chunk = next(chunks, None)
|
|
241
|
+
if chunk == "":
|
|
242
|
+
# Empty source items are not the wire's EOF control.
|
|
243
|
+
continue
|
|
252
244
|
if stopped.wait(pacer.delay()):
|
|
253
245
|
break
|
|
254
246
|
socket.send(json.dumps({"text": chunk if chunk is not None else ""}))
|
|
@@ -365,6 +357,8 @@ class AvarthaAsyncRealtimeTextToSpeechClient:
|
|
|
365
357
|
chunks = _achunks(text) if isinstance(text, AsyncIterable) else _sync_chunks(text)
|
|
366
358
|
async with aclosing(chunks):
|
|
367
359
|
async for chunk in chunks:
|
|
360
|
+
if chunk == "":
|
|
361
|
+
continue
|
|
368
362
|
await pacer.pace_async()
|
|
369
363
|
await socket.send(json.dumps({"text": chunk}))
|
|
370
364
|
await pacer.pace_async()
|
|
@@ -510,10 +504,7 @@ class _MultiContextProtocol:
|
|
|
510
504
|
if text == "":
|
|
511
505
|
return _frame(SendTextMulti(text="", context_id=context_id))
|
|
512
506
|
context.pending = True
|
|
513
|
-
|
|
514
|
-
# one, so supply it when the caller's chunk lacks it.
|
|
515
|
-
spaced = text if text.endswith(" ") else text + " "
|
|
516
|
-
return _frame(SendTextMulti(text=spaced, context_id=context_id))
|
|
507
|
+
return _frame(SendTextMulti(text=text, context_id=context_id))
|
|
517
508
|
|
|
518
509
|
def flush(self, context_id: str) -> dict[str, Any]:
|
|
519
510
|
context = self._open(context_id)
|
|
@@ -596,7 +587,7 @@ class MultiContextSession:
|
|
|
596
587
|
self.send(context_id, text)
|
|
597
588
|
|
|
598
589
|
def send(self, context_id: str, text: str) -> None:
|
|
599
|
-
"""Append text, or send an empty-text keepalive, to an open context.
|
|
590
|
+
"""Append text unchanged, or send an empty-text keepalive, to an open context.
|
|
600
591
|
|
|
601
592
|
A keepalive preserves pending speech without generating or flushing it.
|
|
602
593
|
"""
|
|
@@ -764,7 +755,7 @@ class AsyncMultiContextSession:
|
|
|
764
755
|
await self._send(context_id, lambda protocol: protocol.send(context_id, text))
|
|
765
756
|
|
|
766
757
|
async def send(self, context_id: str, text: str) -> None:
|
|
767
|
-
"""Append text, or send an empty-text keepalive, to an open context.
|
|
758
|
+
"""Append text unchanged, or send an empty-text keepalive, to an open context.
|
|
768
759
|
|
|
769
760
|
A keepalive preserves pending speech without generating or flushing it.
|
|
770
761
|
"""
|
avartha/_speech_config.py
CHANGED
|
@@ -54,21 +54,6 @@ def remove_none_from_dict(value: dict[str, Any]) -> dict[str, Any]:
|
|
|
54
54
|
return {key: item for key, item in value.items() if item is not None}
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
SPLITTERS = (".", ",", "?", "!", ";", ":", "—", "-", "(", ")", "[", "]", "}", " ")
|
|
58
|
-
|
|
59
|
-
|
|
60
57
|
def text_chunker(chunks: Iterable[str]) -> Generator[str]:
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
for chunk in chunks:
|
|
64
|
-
if pending.endswith(SPLITTERS):
|
|
65
|
-
yield pending if pending.endswith(" ") else pending + " "
|
|
66
|
-
pending = chunk
|
|
67
|
-
elif chunk.startswith(SPLITTERS):
|
|
68
|
-
part = pending + chunk[0]
|
|
69
|
-
yield part if part.endswith(" ") else part + " "
|
|
70
|
-
pending = chunk[1:]
|
|
71
|
-
else:
|
|
72
|
-
pending += chunk
|
|
73
|
-
if pending:
|
|
74
|
-
yield pending + " "
|
|
58
|
+
"""Pass text through unchanged; the Avartha Platform handles buffering and segmentation."""
|
|
59
|
+
yield from chunks
|
avartha/_speech_http.py
CHANGED
|
@@ -14,6 +14,7 @@ import httpx
|
|
|
14
14
|
|
|
15
15
|
from ._config import not_vendor_host
|
|
16
16
|
from ._exceptions import ApiError, UnprocessableEntityError
|
|
17
|
+
from ._http_errors import wrap_httpx_errors
|
|
17
18
|
from ._models import construct
|
|
18
19
|
from ._speech_config import RequestOptions, jsonable_encoder
|
|
19
20
|
from .errors import ConfigurationError
|
|
@@ -193,19 +194,20 @@ class SpeechHTTPClient:
|
|
|
193
194
|
options = request_options or {}
|
|
194
195
|
for attempt in range(max(0, options.get("max_retries", 2)) + 1):
|
|
195
196
|
request = self._settings.request(self._client, path, params, options)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
197
|
+
with wrap_httpx_errors():
|
|
198
|
+
for redirects in range(self._client.max_redirects + 1):
|
|
199
|
+
response = self._client.send(request, follow_redirects=False)
|
|
200
|
+
if not self._follow_redirects or not response.has_redirect_location:
|
|
201
|
+
break
|
|
202
|
+
try:
|
|
203
|
+
target = _redirect(request, response)
|
|
204
|
+
if redirects == self._client.max_redirects:
|
|
205
|
+
raise httpx.TooManyRedirects(
|
|
206
|
+
"Exceeded maximum allowed redirects.", request=request
|
|
207
|
+
)
|
|
208
|
+
request.url = target
|
|
209
|
+
finally:
|
|
210
|
+
response.close()
|
|
209
211
|
if (
|
|
210
212
|
response.status_code >= 500 or response.status_code in (408, 409, 429)
|
|
211
213
|
) and attempt < options.get("max_retries", 2):
|
|
@@ -258,19 +260,20 @@ class AsyncSpeechHTTPClient:
|
|
|
258
260
|
options = request_options or {}
|
|
259
261
|
for attempt in range(max(0, options.get("max_retries", 2)) + 1):
|
|
260
262
|
request = self._settings.request(self._client, path, params, options)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
263
|
+
with wrap_httpx_errors():
|
|
264
|
+
for redirects in range(self._client.max_redirects + 1):
|
|
265
|
+
response = await self._client.send(request, follow_redirects=False)
|
|
266
|
+
if not self._follow_redirects or not response.has_redirect_location:
|
|
267
|
+
break
|
|
268
|
+
try:
|
|
269
|
+
target = _redirect(request, response)
|
|
270
|
+
if redirects == self._client.max_redirects:
|
|
271
|
+
raise httpx.TooManyRedirects(
|
|
272
|
+
"Exceeded maximum allowed redirects.", request=request
|
|
273
|
+
)
|
|
274
|
+
request.url = target
|
|
275
|
+
finally:
|
|
276
|
+
await response.aclose()
|
|
274
277
|
if (
|
|
275
278
|
response.status_code >= 500 or response.status_code in (408, 409, 429)
|
|
276
279
|
) and attempt < options.get("max_retries", 2):
|
avartha/client.py
CHANGED
|
@@ -43,6 +43,7 @@ class Avartha:
|
|
|
43
43
|
**(openai_options or {}),
|
|
44
44
|
)
|
|
45
45
|
self.realtime = self.openai.realtime
|
|
46
|
+
self.models = self.openai.models
|
|
46
47
|
|
|
47
48
|
@cached_property
|
|
48
49
|
def elevenlabs(self) -> ElevenLabs:
|
|
@@ -111,6 +112,7 @@ class AsyncAvartha:
|
|
|
111
112
|
**(openai_options or {}),
|
|
112
113
|
)
|
|
113
114
|
self.realtime = self.openai.realtime
|
|
115
|
+
self.models = self.openai.models
|
|
114
116
|
|
|
115
117
|
@cached_property
|
|
116
118
|
def elevenlabs(self) -> AsyncElevenLabs:
|
avartha/control.py
CHANGED
|
@@ -9,16 +9,18 @@ import math
|
|
|
9
9
|
import time
|
|
10
10
|
from collections.abc import Mapping
|
|
11
11
|
from types import TracebackType
|
|
12
|
-
from typing import Self
|
|
12
|
+
from typing import Literal, Self
|
|
13
13
|
from urllib.parse import quote, unquote, urlsplit
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
16
|
from pydantic import JsonValue
|
|
17
17
|
|
|
18
18
|
from . import _config
|
|
19
|
+
from ._http_errors import wrap_httpx_errors
|
|
19
20
|
from .errors import EndpointFailedError, PlatformAPIError
|
|
20
21
|
|
|
21
22
|
type Object = dict[str, JsonValue]
|
|
23
|
+
type OrganizationRole = Literal["owner", "admin", "member"]
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def _object(value: JsonValue) -> Object:
|
|
@@ -80,7 +82,7 @@ def _ready(endpoint: Object) -> bool:
|
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
class Control:
|
|
83
|
-
"""
|
|
85
|
+
"""Customer catalog, workspaces, invitations, and dedicated endpoints."""
|
|
84
86
|
|
|
85
87
|
def __init__(
|
|
86
88
|
self,
|
|
@@ -91,13 +93,17 @@ class Control:
|
|
|
91
93
|
http_client: httpx.Client | None = None,
|
|
92
94
|
) -> None:
|
|
93
95
|
_positive(timeout, "timeout")
|
|
94
|
-
self.base_url =
|
|
96
|
+
self.base_url = (
|
|
97
|
+
_config.not_vendor_host(_config.platform_url(base_url), field="base_url")
|
|
98
|
+
+ "/control/v1"
|
|
99
|
+
)
|
|
95
100
|
self._headers = {"Authorization": f"Bearer {_config.api_key(api_key)}"}
|
|
96
101
|
self._timeout = timeout
|
|
97
102
|
self._owns_client = http_client is None
|
|
98
103
|
self._client = http_client if http_client is not None else httpx.Client()
|
|
99
104
|
self.catalog = Catalog(self)
|
|
100
105
|
self.organizations = Organizations(self)
|
|
106
|
+
self.invitations = Invitations(self)
|
|
101
107
|
self.endpoints = Endpoints(self)
|
|
102
108
|
|
|
103
109
|
def request(
|
|
@@ -110,15 +116,16 @@ class Control:
|
|
|
110
116
|
timeout: float | None = None,
|
|
111
117
|
) -> JsonValue:
|
|
112
118
|
"""Call another verified customer route relative to /control/v1."""
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
with wrap_httpx_errors():
|
|
120
|
+
response = self._client.request(
|
|
121
|
+
method,
|
|
122
|
+
_target(self.base_url, path),
|
|
123
|
+
headers=self._headers,
|
|
124
|
+
json=json,
|
|
125
|
+
params=params,
|
|
126
|
+
timeout=self._timeout if timeout is None else timeout,
|
|
127
|
+
follow_redirects=False,
|
|
128
|
+
)
|
|
122
129
|
return _decode(response)
|
|
123
130
|
|
|
124
131
|
def close(self) -> None:
|
|
@@ -149,8 +156,12 @@ class Catalog:
|
|
|
149
156
|
|
|
150
157
|
|
|
151
158
|
class Organizations:
|
|
159
|
+
"""Workspaces visible to the authenticated customer; permissions are server-enforced."""
|
|
160
|
+
|
|
152
161
|
def __init__(self, control: Control) -> None:
|
|
153
162
|
self._control = control
|
|
163
|
+
self.members = OrganizationMembers(control)
|
|
164
|
+
self.invitations = OrganizationInvitations(control)
|
|
154
165
|
|
|
155
166
|
def list(self) -> list[Object]:
|
|
156
167
|
return _objects(self._control.request("GET", "organizations"))
|
|
@@ -158,6 +169,26 @@ class Organizations:
|
|
|
158
169
|
def get(self, organization_id: str) -> Object:
|
|
159
170
|
return _object(self._control.request("GET", f"organizations/{_segment(organization_id)}"))
|
|
160
171
|
|
|
172
|
+
def create(self, *, name: str) -> Object:
|
|
173
|
+
"""Create a workspace with the platform's default allocation."""
|
|
174
|
+
return _object(self._control.request("POST", "organizations", json={"name": name}))
|
|
175
|
+
|
|
176
|
+
def update(self, organization_id: str, *, name: str) -> Object:
|
|
177
|
+
"""Rename a workspace as an owner or admin."""
|
|
178
|
+
return _object(
|
|
179
|
+
self._control.request(
|
|
180
|
+
"PATCH", f"organizations/{_segment(organization_id)}", json={"name": name}
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def delete(self, organization_id: str) -> None:
|
|
185
|
+
"""Delete a workspace as an owner."""
|
|
186
|
+
self._control.request("DELETE", f"organizations/{_segment(organization_id)}")
|
|
187
|
+
|
|
188
|
+
def leave(self, organization_id: str) -> None:
|
|
189
|
+
"""Leave a workspace; the server refuses removal of its last owner."""
|
|
190
|
+
self._control.request("POST", f"organizations/{_segment(organization_id)}/leave")
|
|
191
|
+
|
|
161
192
|
def limits(self, organization_id: str) -> Object:
|
|
162
193
|
"""Return quota policy, current usage, and any pending increase request."""
|
|
163
194
|
return _object(
|
|
@@ -165,6 +196,88 @@ class Organizations:
|
|
|
165
196
|
)
|
|
166
197
|
|
|
167
198
|
|
|
199
|
+
class OrganizationMembers:
|
|
200
|
+
def __init__(self, control: Control) -> None:
|
|
201
|
+
self._control = control
|
|
202
|
+
|
|
203
|
+
def list(self, organization_id: str) -> list[Object]:
|
|
204
|
+
return _objects(
|
|
205
|
+
self._control.request("GET", f"organizations/{_segment(organization_id)}/members")
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def update(self, organization_id: str, user_id: str, *, role: OrganizationRole) -> Object:
|
|
209
|
+
"""Change a member's role, subject to owner/admin and last-owner rules."""
|
|
210
|
+
return _object(
|
|
211
|
+
self._control.request(
|
|
212
|
+
"PATCH",
|
|
213
|
+
f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}",
|
|
214
|
+
json={"role": role},
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def remove(self, organization_id: str, user_id: str) -> None:
|
|
219
|
+
self._control.request(
|
|
220
|
+
"DELETE", f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class OrganizationInvitations:
|
|
225
|
+
def __init__(self, control: Control) -> None:
|
|
226
|
+
self._control = control
|
|
227
|
+
|
|
228
|
+
def list(self, organization_id: str) -> list[Object]:
|
|
229
|
+
return _objects(
|
|
230
|
+
self._control.request("GET", f"organizations/{_segment(organization_id)}/invitations")
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def create(
|
|
234
|
+
self,
|
|
235
|
+
organization_id: str,
|
|
236
|
+
*,
|
|
237
|
+
role: OrganizationRole,
|
|
238
|
+
email: str | None = None,
|
|
239
|
+
entitlement_policy: Object | None = None,
|
|
240
|
+
) -> Object:
|
|
241
|
+
"""Create an invite; supplying email also asks the server to send it.
|
|
242
|
+
|
|
243
|
+
Targeted customer invites require a member entitlement policy. Inspect
|
|
244
|
+
delivery_warning in the response even when creation succeeds.
|
|
245
|
+
"""
|
|
246
|
+
body: Object = {"role": role}
|
|
247
|
+
if email is not None:
|
|
248
|
+
body["email"] = email
|
|
249
|
+
if entitlement_policy is not None:
|
|
250
|
+
body["entitlement_policy"] = entitlement_policy
|
|
251
|
+
return _object(
|
|
252
|
+
self._control.request(
|
|
253
|
+
"POST", f"organizations/{_segment(organization_id)}/invitations", json=body
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def revoke(self, organization_id: str, invitation_id: str) -> Object:
|
|
258
|
+
return _object(
|
|
259
|
+
self._control.request(
|
|
260
|
+
"DELETE",
|
|
261
|
+
f"organizations/{_segment(organization_id)}/invitations/{_segment(invitation_id)}",
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class Invitations:
|
|
267
|
+
"""Preview and accept an invitation as the authenticated recipient."""
|
|
268
|
+
|
|
269
|
+
def __init__(self, control: Control) -> None:
|
|
270
|
+
self._control = control
|
|
271
|
+
|
|
272
|
+
def preview(self, token: str) -> Object:
|
|
273
|
+
"""Read the invitation; the platform's preview route carries its token in the URL."""
|
|
274
|
+
return _object(self._control.request("GET", f"invitations/{_segment(token)}"))
|
|
275
|
+
|
|
276
|
+
def accept(self, *, token: str) -> Object:
|
|
277
|
+
"""Join a workspace, sending the invitation token in the request body."""
|
|
278
|
+
return _object(self._control.request("POST", "invitations/accept", json={"token": token}))
|
|
279
|
+
|
|
280
|
+
|
|
168
281
|
class Endpoints:
|
|
169
282
|
def __init__(self, control: Control) -> None:
|
|
170
283
|
self._control = control
|
|
@@ -258,7 +371,7 @@ class Endpoints:
|
|
|
258
371
|
|
|
259
372
|
|
|
260
373
|
class AsyncControl:
|
|
261
|
-
"""
|
|
374
|
+
"""Async customer catalog, workspaces, invitations, and dedicated endpoints."""
|
|
262
375
|
|
|
263
376
|
def __init__(
|
|
264
377
|
self,
|
|
@@ -269,13 +382,17 @@ class AsyncControl:
|
|
|
269
382
|
http_client: httpx.AsyncClient | None = None,
|
|
270
383
|
) -> None:
|
|
271
384
|
_positive(timeout, "timeout")
|
|
272
|
-
self.base_url =
|
|
385
|
+
self.base_url = (
|
|
386
|
+
_config.not_vendor_host(_config.platform_url(base_url), field="base_url")
|
|
387
|
+
+ "/control/v1"
|
|
388
|
+
)
|
|
273
389
|
self._headers = {"Authorization": f"Bearer {_config.api_key(api_key)}"}
|
|
274
390
|
self._timeout = timeout
|
|
275
391
|
self._owns_client = http_client is None
|
|
276
392
|
self._client = http_client if http_client is not None else httpx.AsyncClient()
|
|
277
393
|
self.catalog = AsyncCatalog(self)
|
|
278
394
|
self.organizations = AsyncOrganizations(self)
|
|
395
|
+
self.invitations = AsyncInvitations(self)
|
|
279
396
|
self.endpoints = AsyncEndpoints(self)
|
|
280
397
|
|
|
281
398
|
async def request(
|
|
@@ -288,15 +405,16 @@ class AsyncControl:
|
|
|
288
405
|
timeout: float | None = None,
|
|
289
406
|
) -> JsonValue:
|
|
290
407
|
"""Call another verified customer route relative to /control/v1."""
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
408
|
+
with wrap_httpx_errors():
|
|
409
|
+
response = await self._client.request(
|
|
410
|
+
method,
|
|
411
|
+
_target(self.base_url, path),
|
|
412
|
+
headers=self._headers,
|
|
413
|
+
json=json,
|
|
414
|
+
params=params,
|
|
415
|
+
timeout=self._timeout if timeout is None else timeout,
|
|
416
|
+
follow_redirects=False,
|
|
417
|
+
)
|
|
300
418
|
return _decode(response)
|
|
301
419
|
|
|
302
420
|
async def close(self) -> None:
|
|
@@ -327,8 +445,12 @@ class AsyncCatalog:
|
|
|
327
445
|
|
|
328
446
|
|
|
329
447
|
class AsyncOrganizations:
|
|
448
|
+
"""Workspaces visible to the authenticated customer; permissions are server-enforced."""
|
|
449
|
+
|
|
330
450
|
def __init__(self, control: AsyncControl) -> None:
|
|
331
451
|
self._control = control
|
|
452
|
+
self.members = AsyncOrganizationMembers(control)
|
|
453
|
+
self.invitations = AsyncOrganizationInvitations(control)
|
|
332
454
|
|
|
333
455
|
async def list(self) -> list[Object]:
|
|
334
456
|
return _objects(await self._control.request("GET", "organizations"))
|
|
@@ -338,6 +460,26 @@ class AsyncOrganizations:
|
|
|
338
460
|
await self._control.request("GET", f"organizations/{_segment(organization_id)}")
|
|
339
461
|
)
|
|
340
462
|
|
|
463
|
+
async def create(self, *, name: str) -> Object:
|
|
464
|
+
"""Create a workspace with the platform's default allocation."""
|
|
465
|
+
return _object(await self._control.request("POST", "organizations", json={"name": name}))
|
|
466
|
+
|
|
467
|
+
async def update(self, organization_id: str, *, name: str) -> Object:
|
|
468
|
+
"""Rename a workspace as an owner or admin."""
|
|
469
|
+
return _object(
|
|
470
|
+
await self._control.request(
|
|
471
|
+
"PATCH", f"organizations/{_segment(organization_id)}", json={"name": name}
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
async def delete(self, organization_id: str) -> None:
|
|
476
|
+
"""Delete a workspace as an owner."""
|
|
477
|
+
await self._control.request("DELETE", f"organizations/{_segment(organization_id)}")
|
|
478
|
+
|
|
479
|
+
async def leave(self, organization_id: str) -> None:
|
|
480
|
+
"""Leave a workspace; the server refuses removal of its last owner."""
|
|
481
|
+
await self._control.request("POST", f"organizations/{_segment(organization_id)}/leave")
|
|
482
|
+
|
|
341
483
|
async def limits(self, organization_id: str) -> Object:
|
|
342
484
|
"""Return quota policy, current usage, and any pending increase request."""
|
|
343
485
|
return _object(
|
|
@@ -345,6 +487,92 @@ class AsyncOrganizations:
|
|
|
345
487
|
)
|
|
346
488
|
|
|
347
489
|
|
|
490
|
+
class AsyncOrganizationMembers:
|
|
491
|
+
def __init__(self, control: AsyncControl) -> None:
|
|
492
|
+
self._control = control
|
|
493
|
+
|
|
494
|
+
async def list(self, organization_id: str) -> list[Object]:
|
|
495
|
+
return _objects(
|
|
496
|
+
await self._control.request("GET", f"organizations/{_segment(organization_id)}/members")
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
async def update(self, organization_id: str, user_id: str, *, role: OrganizationRole) -> Object:
|
|
500
|
+
"""Change a member's role, subject to owner/admin and last-owner rules."""
|
|
501
|
+
return _object(
|
|
502
|
+
await self._control.request(
|
|
503
|
+
"PATCH",
|
|
504
|
+
f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}",
|
|
505
|
+
json={"role": role},
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
async def remove(self, organization_id: str, user_id: str) -> None:
|
|
510
|
+
await self._control.request(
|
|
511
|
+
"DELETE", f"organizations/{_segment(organization_id)}/members/{_segment(user_id)}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class AsyncOrganizationInvitations:
|
|
516
|
+
def __init__(self, control: AsyncControl) -> None:
|
|
517
|
+
self._control = control
|
|
518
|
+
|
|
519
|
+
async def list(self, organization_id: str) -> list[Object]:
|
|
520
|
+
return _objects(
|
|
521
|
+
await self._control.request(
|
|
522
|
+
"GET", f"organizations/{_segment(organization_id)}/invitations"
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
async def create(
|
|
527
|
+
self,
|
|
528
|
+
organization_id: str,
|
|
529
|
+
*,
|
|
530
|
+
role: OrganizationRole,
|
|
531
|
+
email: str | None = None,
|
|
532
|
+
entitlement_policy: Object | None = None,
|
|
533
|
+
) -> Object:
|
|
534
|
+
"""Create an invite; supplying email also asks the server to send it.
|
|
535
|
+
|
|
536
|
+
Targeted customer invites require a member entitlement policy. Inspect
|
|
537
|
+
delivery_warning in the response even when creation succeeds.
|
|
538
|
+
"""
|
|
539
|
+
body: Object = {"role": role}
|
|
540
|
+
if email is not None:
|
|
541
|
+
body["email"] = email
|
|
542
|
+
if entitlement_policy is not None:
|
|
543
|
+
body["entitlement_policy"] = entitlement_policy
|
|
544
|
+
return _object(
|
|
545
|
+
await self._control.request(
|
|
546
|
+
"POST", f"organizations/{_segment(organization_id)}/invitations", json=body
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
async def revoke(self, organization_id: str, invitation_id: str) -> Object:
|
|
551
|
+
return _object(
|
|
552
|
+
await self._control.request(
|
|
553
|
+
"DELETE",
|
|
554
|
+
f"organizations/{_segment(organization_id)}/invitations/{_segment(invitation_id)}",
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class AsyncInvitations:
|
|
560
|
+
"""Preview and accept an invitation as the authenticated recipient."""
|
|
561
|
+
|
|
562
|
+
def __init__(self, control: AsyncControl) -> None:
|
|
563
|
+
self._control = control
|
|
564
|
+
|
|
565
|
+
async def preview(self, token: str) -> Object:
|
|
566
|
+
"""Read the invitation; the platform's preview route carries its token in the URL."""
|
|
567
|
+
return _object(await self._control.request("GET", f"invitations/{_segment(token)}"))
|
|
568
|
+
|
|
569
|
+
async def accept(self, *, token: str) -> Object:
|
|
570
|
+
"""Join a workspace, sending the invitation token in the request body."""
|
|
571
|
+
return _object(
|
|
572
|
+
await self._control.request("POST", "invitations/accept", json={"token": token})
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
348
576
|
class AsyncEndpoints:
|
|
349
577
|
def __init__(self, control: AsyncControl) -> None:
|
|
350
578
|
self._control = control
|