verbum-sdk 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.
- verbum/__init__.py +54 -0
- verbum/_types.py +29 -0
- verbum/_version.py +1 -0
- verbum/auth/__init__.py +15 -0
- verbum/auth/types.py +59 -0
- verbum/client.py +122 -0
- verbum/py.typed +0 -0
- verbum/rest/__init__.py +272 -0
- verbum/rest/_serde.py +14 -0
- verbum/rest/invitations.py +20 -0
- verbum/rest/metrics.py +20 -0
- verbum/rest/projects.py +20 -0
- verbum/rest/scribe.py +286 -0
- verbum/rest/speech.py +107 -0
- verbum/rest/text_analysis.py +243 -0
- verbum/rest/translator.py +119 -0
- verbum/rest/types.py +466 -0
- verbum/rest/usage.py +63 -0
- verbum/rest/users.py +20 -0
- verbum/stt/__init__.py +197 -0
- verbum/stt/session.py +395 -0
- verbum/stt/types.py +64 -0
- verbum/ws/__init__.py +438 -0
- verbum/ws/types.py +154 -0
- verbum_sdk-1.0.0.dist-info/METADATA +234 -0
- verbum_sdk-1.0.0.dist-info/RECORD +28 -0
- verbum_sdk-1.0.0.dist-info/WHEEL +4 -0
- verbum_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
verbum/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Verbum SDK — Python client for Verbum AI speech services (sync and async)."""
|
|
2
|
+
|
|
3
|
+
from verbum._types import VerbumClientOptions
|
|
4
|
+
from verbum._version import SDK_VERSION
|
|
5
|
+
from verbum.auth import ApiToken, ApiTokenAuth, AuthConfig, AuthProvider, create_api_token
|
|
6
|
+
from verbum.client import AsyncRestSurface, AsyncVerbumClient, RestSurface, VerbumClient
|
|
7
|
+
from verbum.rest import CircuitOpenError, UnauthorizedError
|
|
8
|
+
from verbum.stt import AsyncSttModule, AsyncSttSession, SttModule, SttSession
|
|
9
|
+
from verbum.stt.types import SttConnectOptions, SttSessionState, SttStateChangeEvent
|
|
10
|
+
from verbum.ws.types import (
|
|
11
|
+
PiiEntity,
|
|
12
|
+
SpeechNormalization,
|
|
13
|
+
SpeechResult,
|
|
14
|
+
SpeechTranslation,
|
|
15
|
+
SpeechWord,
|
|
16
|
+
WsOptions,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = SDK_VERSION
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"SDK_VERSION",
|
|
24
|
+
# Client
|
|
25
|
+
"VerbumClient",
|
|
26
|
+
"AsyncVerbumClient",
|
|
27
|
+
"VerbumClientOptions",
|
|
28
|
+
"RestSurface",
|
|
29
|
+
"AsyncRestSurface",
|
|
30
|
+
# Auth
|
|
31
|
+
"ApiToken",
|
|
32
|
+
"ApiTokenAuth",
|
|
33
|
+
"AuthConfig",
|
|
34
|
+
"AuthProvider",
|
|
35
|
+
"create_api_token",
|
|
36
|
+
# REST errors
|
|
37
|
+
"UnauthorizedError",
|
|
38
|
+
"CircuitOpenError",
|
|
39
|
+
# STT
|
|
40
|
+
"SttModule",
|
|
41
|
+
"AsyncSttModule",
|
|
42
|
+
"SttSession",
|
|
43
|
+
"AsyncSttSession",
|
|
44
|
+
"SttConnectOptions",
|
|
45
|
+
"SttSessionState",
|
|
46
|
+
"SttStateChangeEvent",
|
|
47
|
+
# WebSocket / speech result types
|
|
48
|
+
"WsOptions",
|
|
49
|
+
"PiiEntity",
|
|
50
|
+
"SpeechWord",
|
|
51
|
+
"SpeechTranslation",
|
|
52
|
+
"SpeechNormalization",
|
|
53
|
+
"SpeechResult",
|
|
54
|
+
]
|
verbum/_types.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Top-level client configuration types.
|
|
2
|
+
|
|
3
|
+
Mirrors ``src/types/index.ts`` in the reference TypeScript SDK.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from verbum.auth import ApiToken
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class VerbumClientOptions:
|
|
16
|
+
"""Configuration options for :class:`VerbumClient` / :class:`AsyncVerbumClient`."""
|
|
17
|
+
|
|
18
|
+
api_token: ApiToken
|
|
19
|
+
"""Use :func:`verbum.create_api_token` to construct one from a plain string."""
|
|
20
|
+
|
|
21
|
+
base_url: str
|
|
22
|
+
"""Base URL for the Verbum service, e.g. ``"wss://sdk.verbum.ai"``."""
|
|
23
|
+
|
|
24
|
+
on_unauthorized: Callable[[object], None] | None = None
|
|
25
|
+
"""Called when a REST request returns HTTP 401.
|
|
26
|
+
|
|
27
|
+
Notification hook only — ``UnauthorizedError`` is still raised after this
|
|
28
|
+
callback fires. If omitted, no callback runs before the error is raised.
|
|
29
|
+
"""
|
verbum/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SDK_VERSION = "1.0.0"
|
verbum/auth/__init__.py
ADDED
verbum/auth/types.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Auth primitives shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Mirrors ``src/auth/*.ts`` in the reference TypeScript SDK.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import NewType, Protocol
|
|
10
|
+
|
|
11
|
+
ApiToken = NewType("ApiToken", str)
|
|
12
|
+
"""Distinct token type. Use :func:`create_api_token` to construct one."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_api_token(token: str) -> ApiToken:
|
|
16
|
+
"""Casts a plain string to an :class:`ApiToken`.
|
|
17
|
+
|
|
18
|
+
Call this once at application entry-point when constructing a client.
|
|
19
|
+
"""
|
|
20
|
+
return ApiToken(token)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class AuthConfig:
|
|
25
|
+
"""Configuration for an authentication provider."""
|
|
26
|
+
|
|
27
|
+
api_token: ApiToken
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthProvider(Protocol):
|
|
31
|
+
"""Contract that all auth providers must fulfill."""
|
|
32
|
+
|
|
33
|
+
def get_token(self) -> ApiToken:
|
|
34
|
+
"""Returns the resolved API token."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def rest_headers(self) -> dict[str, str]:
|
|
38
|
+
"""Headers to merge into every REST request."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def socketio_auth(self) -> dict[str, object]:
|
|
42
|
+
"""The ``auth`` payload to pass when opening a Socket.IO connection."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ApiTokenAuth:
|
|
47
|
+
"""API-token-based auth provider (default)."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: AuthConfig) -> None:
|
|
50
|
+
self._token = config.api_token
|
|
51
|
+
|
|
52
|
+
def get_token(self) -> ApiToken:
|
|
53
|
+
return self._token
|
|
54
|
+
|
|
55
|
+
def rest_headers(self) -> dict[str, str]:
|
|
56
|
+
return {"x-api-key": self._token}
|
|
57
|
+
|
|
58
|
+
def socketio_auth(self) -> dict[str, object]:
|
|
59
|
+
return {"token": self._token}
|
verbum/client.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Main Verbum SDK clients — sync and async.
|
|
2
|
+
|
|
3
|
+
Mirrors ``src/client.ts`` in the reference TypeScript SDK.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from verbum._types import VerbumClientOptions
|
|
12
|
+
from verbum.auth import ApiTokenAuth, AuthConfig
|
|
13
|
+
from verbum.rest.invitations import AsyncInvitationsApi, InvitationsApi
|
|
14
|
+
from verbum.rest.metrics import AsyncMetricsApi, MetricsApi
|
|
15
|
+
from verbum.rest.projects import AsyncProjectsApi, ProjectsApi
|
|
16
|
+
from verbum.rest.scribe import AsyncScribeApi, ScribeApi
|
|
17
|
+
from verbum.rest.speech import AsyncSpeechApi, SpeechApi
|
|
18
|
+
from verbum.rest.text_analysis import AsyncTextAnalysisApi, TextAnalysisApi
|
|
19
|
+
from verbum.rest.translator import AsyncTranslatorApi, TranslatorApi
|
|
20
|
+
from verbum.rest.usage import AsyncUsageApi, UsageApi
|
|
21
|
+
from verbum.rest.users import AsyncUsersApi, UsersApi
|
|
22
|
+
from verbum.stt import AsyncSttModule, SttModule
|
|
23
|
+
|
|
24
|
+
__all__ = ["VerbumClient", "AsyncVerbumClient", "RestSurface", "AsyncRestSurface"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rest_base_url(ws_base_url: str) -> str:
|
|
28
|
+
"""Derives the REST base URL from the WebSocket base URL."""
|
|
29
|
+
return re.sub(r"^wss?://", "https://", ws_base_url)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RestSurface:
|
|
34
|
+
"""REST API surface exposed at ``client.rest.*``."""
|
|
35
|
+
|
|
36
|
+
users: UsersApi
|
|
37
|
+
projects: ProjectsApi
|
|
38
|
+
invitations: InvitationsApi
|
|
39
|
+
usage: UsageApi
|
|
40
|
+
metrics: MetricsApi
|
|
41
|
+
translator: TranslatorApi
|
|
42
|
+
text_analysis: TextAnalysisApi
|
|
43
|
+
speech: SpeechApi
|
|
44
|
+
scribe: ScribeApi
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class AsyncRestSurface:
|
|
49
|
+
"""REST API surface exposed at ``client.rest.*`` (async)."""
|
|
50
|
+
|
|
51
|
+
users: AsyncUsersApi
|
|
52
|
+
projects: AsyncProjectsApi
|
|
53
|
+
invitations: AsyncInvitationsApi
|
|
54
|
+
usage: AsyncUsageApi
|
|
55
|
+
metrics: AsyncMetricsApi
|
|
56
|
+
translator: AsyncTranslatorApi
|
|
57
|
+
text_analysis: AsyncTextAnalysisApi
|
|
58
|
+
speech: AsyncSpeechApi
|
|
59
|
+
scribe: AsyncScribeApi
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class VerbumClient:
|
|
63
|
+
"""Sync Verbum SDK client.
|
|
64
|
+
|
|
65
|
+
Example::
|
|
66
|
+
|
|
67
|
+
client = VerbumClient(VerbumClientOptions(
|
|
68
|
+
api_token=create_api_token("sk-..."),
|
|
69
|
+
base_url="wss://sdk.verbum.ai",
|
|
70
|
+
))
|
|
71
|
+
with client.stt.connect(SttConnectOptions(language=["en-US"], encoding="PCM")) as session:
|
|
72
|
+
session.on_recognized(lambda r: print(r.text))
|
|
73
|
+
session.stream(pcm_bytes)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, options: VerbumClientOptions) -> None:
|
|
77
|
+
self._auth = ApiTokenAuth(AuthConfig(api_token=options.api_token))
|
|
78
|
+
self.stt = SttModule(options.base_url, self._auth)
|
|
79
|
+
|
|
80
|
+
rest_base = _rest_base_url(options.base_url)
|
|
81
|
+
on401 = options.on_unauthorized
|
|
82
|
+
self.rest = RestSurface(
|
|
83
|
+
users=UsersApi(rest_base, self._auth, on401),
|
|
84
|
+
projects=ProjectsApi(rest_base, self._auth, on401),
|
|
85
|
+
invitations=InvitationsApi(rest_base, self._auth, on401),
|
|
86
|
+
usage=UsageApi(rest_base, self._auth, on401),
|
|
87
|
+
metrics=MetricsApi(rest_base, self._auth, on401),
|
|
88
|
+
translator=TranslatorApi(rest_base, self._auth, on401),
|
|
89
|
+
text_analysis=TextAnalysisApi(rest_base, self._auth, on401),
|
|
90
|
+
speech=SpeechApi(rest_base, self._auth, on401),
|
|
91
|
+
scribe=ScribeApi(rest_base, self._auth, on401),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Feature-layer shortcuts
|
|
95
|
+
self.batch: ScribeApi = self.rest.scribe
|
|
96
|
+
self.usage: UsageApi = self.rest.usage
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AsyncVerbumClient:
|
|
100
|
+
"""Async Verbum SDK client. See :class:`VerbumClient` for usage."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, options: VerbumClientOptions) -> None:
|
|
103
|
+
self._auth = ApiTokenAuth(AuthConfig(api_token=options.api_token))
|
|
104
|
+
self.stt = AsyncSttModule(options.base_url, self._auth)
|
|
105
|
+
|
|
106
|
+
rest_base = _rest_base_url(options.base_url)
|
|
107
|
+
on401 = options.on_unauthorized
|
|
108
|
+
self.rest = AsyncRestSurface(
|
|
109
|
+
users=AsyncUsersApi(rest_base, self._auth, on401),
|
|
110
|
+
projects=AsyncProjectsApi(rest_base, self._auth, on401),
|
|
111
|
+
invitations=AsyncInvitationsApi(rest_base, self._auth, on401),
|
|
112
|
+
usage=AsyncUsageApi(rest_base, self._auth, on401),
|
|
113
|
+
metrics=AsyncMetricsApi(rest_base, self._auth, on401),
|
|
114
|
+
translator=AsyncTranslatorApi(rest_base, self._auth, on401),
|
|
115
|
+
text_analysis=AsyncTextAnalysisApi(rest_base, self._auth, on401),
|
|
116
|
+
speech=AsyncSpeechApi(rest_base, self._auth, on401),
|
|
117
|
+
scribe=AsyncScribeApi(rest_base, self._auth, on401),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Feature-layer shortcuts
|
|
121
|
+
self.batch: AsyncScribeApi = self.rest.scribe
|
|
122
|
+
self.usage: AsyncUsageApi = self.rest.usage
|
verbum/py.typed
ADDED
|
File without changes
|
verbum/rest/__init__.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Base REST client shared by every ``verbum.rest.*`` resource module.
|
|
2
|
+
|
|
3
|
+
Mirrors ``src/rest/index.ts`` in the reference TypeScript SDK:
|
|
4
|
+
|
|
5
|
+
- Applies API-token auth and an ``X-Verbum-SDK: python/{version}`` header
|
|
6
|
+
on every request.
|
|
7
|
+
- Retries on 5xx up to 3 times with exponential backoff; no retry on 4xx.
|
|
8
|
+
- Circuit breaker: opens after consecutive 5xx/network failures and
|
|
9
|
+
fast-fails until a reset timeout elapses.
|
|
10
|
+
- Raises :class:`UnauthorizedError` on HTTP 401.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
from verbum._version import SDK_VERSION
|
|
23
|
+
from verbum.auth import AuthProvider
|
|
24
|
+
|
|
25
|
+
__all__ = ["SDK_VERSION", "UnauthorizedError", "CircuitOpenError", "RestClient", "AsyncRestClient"]
|
|
26
|
+
|
|
27
|
+
_MAX_RETRIES = 3
|
|
28
|
+
_BASE_BACKOFF_SECONDS = 1.0
|
|
29
|
+
_FAILURE_THRESHOLD = 3
|
|
30
|
+
_RESET_TIMEOUT_SECONDS = 30.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnauthorizedError(Exception):
|
|
34
|
+
"""Raised when a REST request returns HTTP 401 (Unauthorized).
|
|
35
|
+
|
|
36
|
+
The underlying response is attached for callers that need to inspect it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, response: object) -> None:
|
|
40
|
+
super().__init__("Unauthorized: API token is invalid or has been revoked (HTTP 401)")
|
|
41
|
+
self.response = response
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CircuitOpenError(Exception):
|
|
45
|
+
"""Raised when a request is rejected because the circuit breaker is open.
|
|
46
|
+
|
|
47
|
+
The circuit will allow a probe request after ``reset_at`` (seconds
|
|
48
|
+
since the epoch, as returned by ``time.time()``).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, reset_at: float) -> None:
|
|
52
|
+
super().__init__(f"Circuit is open — next probe allowed after {reset_at}")
|
|
53
|
+
self.reset_at = reset_at
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _CircuitBreaker:
|
|
57
|
+
"""Opens after ``failure_threshold`` consecutive failures; fast-fails
|
|
58
|
+
requests until ``reset_timeout_seconds`` has elapsed, then allows a
|
|
59
|
+
single probe request through.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
failure_threshold: int = _FAILURE_THRESHOLD,
|
|
65
|
+
reset_timeout_seconds: float = _RESET_TIMEOUT_SECONDS,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._failure_threshold = failure_threshold
|
|
68
|
+
self._reset_timeout_seconds = reset_timeout_seconds
|
|
69
|
+
self._state: str = "closed" # "closed" | "open" | "half-open"
|
|
70
|
+
self._failures = 0
|
|
71
|
+
self._opened_at = 0.0
|
|
72
|
+
self._probe_in_flight = False
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def next_probe_at(self) -> float:
|
|
76
|
+
return self._opened_at + self._reset_timeout_seconds
|
|
77
|
+
|
|
78
|
+
def can_request(self) -> bool:
|
|
79
|
+
if self._state == "closed":
|
|
80
|
+
return True
|
|
81
|
+
if self._state == "half-open":
|
|
82
|
+
# Allow only one probe at a time to avoid thundering-herd on recovery.
|
|
83
|
+
if self._probe_in_flight:
|
|
84
|
+
return False
|
|
85
|
+
self._probe_in_flight = True
|
|
86
|
+
return True
|
|
87
|
+
# OPEN — check whether the reset window has elapsed.
|
|
88
|
+
if time.time() - self._opened_at >= self._reset_timeout_seconds:
|
|
89
|
+
self._state = "half-open"
|
|
90
|
+
self._probe_in_flight = True
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
def record_success(self) -> None:
|
|
95
|
+
self._state = "closed"
|
|
96
|
+
self._failures = 0
|
|
97
|
+
self._probe_in_flight = False
|
|
98
|
+
|
|
99
|
+
def record_failure(self) -> None:
|
|
100
|
+
self._failures += 1
|
|
101
|
+
self._probe_in_flight = False
|
|
102
|
+
if self._state == "half-open":
|
|
103
|
+
# Failed probe → re-open with a fresh timer.
|
|
104
|
+
self._state = "open"
|
|
105
|
+
self._opened_at = time.time()
|
|
106
|
+
elif self._state == "closed" and self._failures >= self._failure_threshold:
|
|
107
|
+
# Threshold reached while closed → open for the first time.
|
|
108
|
+
self._state = "open"
|
|
109
|
+
self._opened_at = time.time()
|
|
110
|
+
# If already open, preserve the original opened_at so the window is not extended.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sdk_headers(auth_provider: AuthProvider, extra: dict[str, str] | None) -> dict[str, str]:
|
|
114
|
+
headers = {"X-Verbum-SDK": f"python/{SDK_VERSION}"}
|
|
115
|
+
if extra:
|
|
116
|
+
headers.update(extra)
|
|
117
|
+
headers.update(auth_provider.rest_headers())
|
|
118
|
+
return headers
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RestClient:
|
|
122
|
+
"""Sync base REST client, built on ``requests.Session``."""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
base_url: str,
|
|
127
|
+
auth_provider: AuthProvider,
|
|
128
|
+
on_unauthorized: Callable[[requests.Response], None] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._base_url = base_url
|
|
131
|
+
self._auth_provider = auth_provider
|
|
132
|
+
self._on_unauthorized = on_unauthorized
|
|
133
|
+
self._circuit = _CircuitBreaker()
|
|
134
|
+
self._session = requests.Session()
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
"""Closes the underlying ``requests.Session``, releasing pooled connections."""
|
|
138
|
+
self._session.close()
|
|
139
|
+
|
|
140
|
+
def __enter__(self) -> RestClient:
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(self, *_: object) -> None:
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
def _request(
|
|
147
|
+
self,
|
|
148
|
+
method: str,
|
|
149
|
+
path: str,
|
|
150
|
+
*,
|
|
151
|
+
json: Any = None,
|
|
152
|
+
params: dict[str, Any] | None = None,
|
|
153
|
+
headers: dict[str, str] | None = None,
|
|
154
|
+
) -> requests.Response:
|
|
155
|
+
"""Makes an authenticated request with retry and circuit-breaker logic.
|
|
156
|
+
|
|
157
|
+
- Raises :class:`CircuitOpenError` immediately if the circuit is open.
|
|
158
|
+
- Retries on 5xx up to ``_MAX_RETRIES`` times with exponential backoff.
|
|
159
|
+
- Returns the last 5xx response once retries are exhausted.
|
|
160
|
+
- No retry on 4xx — returns the response immediately.
|
|
161
|
+
- Raises :class:`UnauthorizedError` on HTTP 401.
|
|
162
|
+
- Retries on network errors; re-raises after max retries.
|
|
163
|
+
"""
|
|
164
|
+
if not self._circuit.can_request():
|
|
165
|
+
raise CircuitOpenError(self._circuit.next_probe_at)
|
|
166
|
+
|
|
167
|
+
url = f"{self._base_url}{path}"
|
|
168
|
+
request_headers = _sdk_headers(self._auth_provider, headers)
|
|
169
|
+
|
|
170
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
171
|
+
try:
|
|
172
|
+
response = self._session.request(
|
|
173
|
+
method, url, json=json, params=params, headers=request_headers
|
|
174
|
+
)
|
|
175
|
+
except requests.RequestException:
|
|
176
|
+
self._circuit.record_failure()
|
|
177
|
+
if attempt < _MAX_RETRIES:
|
|
178
|
+
time.sleep((2**attempt) * _BASE_BACKOFF_SECONDS)
|
|
179
|
+
continue
|
|
180
|
+
raise
|
|
181
|
+
|
|
182
|
+
if response.status_code == 401:
|
|
183
|
+
# Auth failure is not a server fault — do not penalise the circuit.
|
|
184
|
+
self._circuit.record_success()
|
|
185
|
+
if self._on_unauthorized is not None:
|
|
186
|
+
self._on_unauthorized(response)
|
|
187
|
+
raise UnauthorizedError(response)
|
|
188
|
+
|
|
189
|
+
if response.status_code >= 500:
|
|
190
|
+
self._circuit.record_failure()
|
|
191
|
+
if attempt < _MAX_RETRIES:
|
|
192
|
+
time.sleep((2**attempt) * _BASE_BACKOFF_SECONDS)
|
|
193
|
+
continue
|
|
194
|
+
return response
|
|
195
|
+
|
|
196
|
+
# 2xx / 3xx / 4xx (non-401): surface immediately, no retry.
|
|
197
|
+
self._circuit.record_success()
|
|
198
|
+
return response
|
|
199
|
+
|
|
200
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AsyncRestClient:
|
|
204
|
+
"""Async base REST client, built on ``httpx.AsyncClient``."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
base_url: str,
|
|
209
|
+
auth_provider: AuthProvider,
|
|
210
|
+
on_unauthorized: Callable[[httpx.Response], None] | None = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
self._base_url = base_url
|
|
213
|
+
self._auth_provider = auth_provider
|
|
214
|
+
self._on_unauthorized = on_unauthorized
|
|
215
|
+
self._circuit = _CircuitBreaker()
|
|
216
|
+
self._client = httpx.AsyncClient()
|
|
217
|
+
|
|
218
|
+
async def aclose(self) -> None:
|
|
219
|
+
"""Closes the underlying ``httpx.AsyncClient``, releasing connections."""
|
|
220
|
+
await self._client.aclose()
|
|
221
|
+
|
|
222
|
+
async def __aenter__(self) -> AsyncRestClient:
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
async def __aexit__(self, *_: object) -> None:
|
|
226
|
+
await self.aclose()
|
|
227
|
+
|
|
228
|
+
async def _request(
|
|
229
|
+
self,
|
|
230
|
+
method: str,
|
|
231
|
+
path: str,
|
|
232
|
+
*,
|
|
233
|
+
json: Any = None,
|
|
234
|
+
params: dict[str, Any] | None = None,
|
|
235
|
+
headers: dict[str, str] | None = None,
|
|
236
|
+
) -> httpx.Response:
|
|
237
|
+
"""Async counterpart of :meth:`RestClient._request`. See there for behavior."""
|
|
238
|
+
if not self._circuit.can_request():
|
|
239
|
+
raise CircuitOpenError(self._circuit.next_probe_at)
|
|
240
|
+
|
|
241
|
+
url = f"{self._base_url}{path}"
|
|
242
|
+
request_headers = _sdk_headers(self._auth_provider, headers)
|
|
243
|
+
|
|
244
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
245
|
+
try:
|
|
246
|
+
response = await self._client.request(
|
|
247
|
+
method, url, json=json, params=params, headers=request_headers
|
|
248
|
+
)
|
|
249
|
+
except httpx.HTTPError:
|
|
250
|
+
self._circuit.record_failure()
|
|
251
|
+
if attempt < _MAX_RETRIES:
|
|
252
|
+
await asyncio.sleep((2**attempt) * _BASE_BACKOFF_SECONDS)
|
|
253
|
+
continue
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
if response.status_code == 401:
|
|
257
|
+
self._circuit.record_success()
|
|
258
|
+
if self._on_unauthorized is not None:
|
|
259
|
+
self._on_unauthorized(response)
|
|
260
|
+
raise UnauthorizedError(response)
|
|
261
|
+
|
|
262
|
+
if response.status_code >= 500:
|
|
263
|
+
self._circuit.record_failure()
|
|
264
|
+
if attempt < _MAX_RETRIES:
|
|
265
|
+
await asyncio.sleep((2**attempt) * _BASE_BACKOFF_SECONDS)
|
|
266
|
+
continue
|
|
267
|
+
return response
|
|
268
|
+
|
|
269
|
+
self._circuit.record_success()
|
|
270
|
+
return response
|
|
271
|
+
|
|
272
|
+
raise AssertionError("unreachable") # pragma: no cover
|
verbum/rest/_serde.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Tiny (de)serialization helpers shared by the ``verbum.rest.*`` resource
|
|
2
|
+
modules. Not part of the public API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compact(d: dict[str, Any]) -> dict[str, Any]:
|
|
11
|
+
"""Drops ``None``-valued keys so optional fields are omitted from the
|
|
12
|
+
JSON request body rather than sent as ``null``.
|
|
13
|
+
"""
|
|
14
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""API client for the Invitations resource.
|
|
2
|
+
|
|
3
|
+
Out of scope for VSDK-763: this belongs to the internal manager-api, which
|
|
4
|
+
has no public endpoint spec in ``public-docs/``. Left unimplemented until
|
|
5
|
+
that spec exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from verbum.rest import AsyncRestClient, RestClient
|
|
11
|
+
|
|
12
|
+
__all__ = ["InvitationsApi", "AsyncInvitationsApi"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvitationsApi(RestClient):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncInvitationsApi(AsyncRestClient):
|
|
20
|
+
pass
|
verbum/rest/metrics.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""API client for Metric Conversion Ratios.
|
|
2
|
+
|
|
3
|
+
Out of scope for VSDK-763: this belongs to the internal manager-api, which
|
|
4
|
+
has no public endpoint spec in ``public-docs/``. Left unimplemented until
|
|
5
|
+
that spec exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from verbum.rest import AsyncRestClient, RestClient
|
|
11
|
+
|
|
12
|
+
__all__ = ["MetricsApi", "AsyncMetricsApi"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MetricsApi(RestClient):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncMetricsApi(AsyncRestClient):
|
|
20
|
+
pass
|
verbum/rest/projects.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""API client for the Projects resource.
|
|
2
|
+
|
|
3
|
+
Out of scope for VSDK-763: this belongs to the internal manager-api, which
|
|
4
|
+
has no public endpoint spec in ``public-docs/``. Left unimplemented until
|
|
5
|
+
that spec exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from verbum.rest import AsyncRestClient, RestClient
|
|
11
|
+
|
|
12
|
+
__all__ = ["ProjectsApi", "AsyncProjectsApi"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProjectsApi(RestClient):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncProjectsApi(AsyncRestClient):
|
|
20
|
+
pass
|