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 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"
@@ -0,0 +1,15 @@
1
+ from verbum.auth.types import (
2
+ ApiToken,
3
+ ApiTokenAuth,
4
+ AuthConfig,
5
+ AuthProvider,
6
+ create_api_token,
7
+ )
8
+
9
+ __all__ = [
10
+ "ApiToken",
11
+ "ApiTokenAuth",
12
+ "AuthConfig",
13
+ "AuthProvider",
14
+ "create_api_token",
15
+ ]
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
@@ -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
@@ -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